TwinKYC

API de anclaje KYC en EVM

Tu exchange envia solo referencias opacas y hashes. El sistema nunca recibe ni almacena datos personales: la cadena guarda unicamente subject_ref y payload_hash.

Prompt de integracion para el backend del exchange

Documento listo para pegar en Codex, Claude o cualquier asistente de codigo: explica como derivar subject_ref y payload_hash, firmar con HMAC, que endpoints llamar, que webhook crear en el exchange y como verificarlo, con checklist y reglas de privacidad.

Base URL

La API vive en esta aplicacion (TanStack server routes), no en la URL de Supabase. Supabase se usa internamente como base de datos.

Base URL: https://kyc.twinbot.io/api/public/v1

Playground con firma HMAC

Pega tu key y tu secreto: la firma se calcula en tu navegador (Web Crypto) y la peticion sale desde tu equipo. Nada se guarda ni se envia a terceros.

Playground con firma HMAC
Firma y ejecuta llamadas reales desde el navegador. Las credenciales solo viven en esta pestaña; usa una API key de pruebas.

Estado del servicio · scope

publico

Autenticacion (HMAC SHA-256)

Cada peticion lleva la key publica y una firma calculada con el secreto entregado una sola vez al crear la API key en el panel.

Cabeceras:
  X-Api-Key: kyc_xxxxxxxxx
  X-Timestamp: 1717171717            (epoch segundos, tolerancia +/- 300s)
  X-Signature: <hex hmac-sha256>

base = `${timestamp}.${METHOD}.${path}.${sha256_hex(body)}`
signature = hmac_sha256(api_secret, base) -> hex

// Ejemplo Node.js
import { createHash, createHmac } from "node:crypto";
const body = JSON.stringify({ subject_ref, payload_hash, kyc_level: 2 });
const ts = Math.floor(Date.now() / 1000).toString();
const path = "/api/public/v1/kyc/stamp";
const base = `${ts}.POST.${path}.${createHash("sha256").update(body).digest("hex")}`;
const sig = createHmac("sha256", API_SECRET).update(base).digest("hex");

Como calcular los hashes (lado exchange)

subject_ref  = keccak256/sha256( user_id_interno + PEPPER_SECRETO )  -> bytes32 0x...
payload_hash = sha256( JSON canonico del expediente KYC )              -> bytes32 0x...

Nunca envies nombre, documento, foto, direccion ni email.

Endpoints

POST /api/public/v1/kyc/stamp
scope requerido: stamp
{
  "subject_ref": "0x…64 hex",
  "payload_hash": "0x…64 hex",
  "kyc_level": 2,
  "country_code": "AR",          // opcional, ISO-3166 alfa-2
  "provider": "sumsub",          // opcional
  "verified_at": "2026-08-02T12:00:00Z",
  "expires_at": "2027-08-02T12:00:00Z",   // opcional, vigencia del KYC
  "policy_hash": "0x…64 hex",             // opcional, version de politica aplicada
  "provider_hash": "0x…64 hex",           // opcional, proveedor sin revelar su nombre
  "jurisdiction_code": 484,               // opcional, ISO-3166 numerico
  "risk_bucket": 250,                     // opcional, 0-1000 (score sin PII)
  "idempotency_key": "kyc-9981"  // opcional
}

202 -> { "anchor_id": "uuid", "status": "queued" }
POST /api/public/v1/kyc/revoke
scope requerido: stamp
{ "subject_ref": "0x…", "reason_hash": "0x…" }
202 -> { "anchor_id": "uuid", "status": "revoke_queued" }
GET /api/public/v1/kyc/{subject_ref}
scope requerido: read
200 -> { "subject_ref": "0x…", "anchors": [ { status, tx_hash, block_number, batch_id, … } ] }
GET /api/public/v1/kyc/{subject_ref}/proof
scope requerido: read
200 -> { leaf_hash, leaf_index, proof: ["0x…"], batch: { merkle_root, tx_hash, … } }
POST /api/public/v1/kyc/verify
scope requerido: read
{ "subject_ref": "0x…", "payload_hash": "0x…" }
200 -> { "valid": true, "stamped_at": 1717171717, "contract": "0x…", "chain_id": 1337 }
GET /api/public/v1/batches/{batch_id}
scope requerido: read
200 -> { merkle_root, leaf_count, status, tx_hash, block_number, period_start, period_end }
GET /api/public/v1/health
scope requerido: publico
200 -> { status: "ok", pending_anchors: 3, chain: { connected: true, chain_id, block } }
POST /api/public/v1/kyc/bulk-stamp
scope requerido: stamp
{ "items": [ { subject_ref, payload_hash, kyc_level, country_code?, provider?, verified_at?, idempotency_key? }, … ] }
Maximo 200 elementos por llamada.

202 -> { "accepted": 198, "duplicated": 2, "results": [ { subject_ref, anchor_id, status } ] }
POST /api/public/v1/kyc/bulk-status
scope requerido: read
{ "subject_refs": ["0x…", "0x…"] }   // maximo 500
200 -> { "results": { "0x…": { status, tx_hash, block_number, batch_id } } }
GET /api/public/v1/stats
scope requerido: read
200 -> { total, confirmed, pending, failed, revoked, last_24h, last_7d, batches: { open, committed } }
GET /api/public/v1/contract?abi=1
scope requerido: read
200 -> { contract: { address, version, status }, network: { chain_id, name, explorer_url }, abi?: [...] }
POST /api/public/v1/kyc/level
scope requerido: stamp
{ "subject_ref": "0x…", "kyc_level": 3, "reason_code": 10 }
202 -> { anchor_id, status: "level_change_queued", new_level: 3 }
POST /api/public/v1/kyc/freeze
scope requerido: stamp
{ "subject_ref": "0x…", "frozen": true, "reason_code": 42 }
202 -> { anchor_id, status: "freeze_queued" }

Congelar deja el anclaje on-chain como no valido sin revocarlo (util para casos AML en revision).
POST /api/public/v1/kyc/extend
scope requerido: stamp
{ "subject_ref": "0x…", "expires_at": "2027-08-02T00:00:00Z" }
202 -> { anchor_id, status: "extend_queued", expires_at }
GET /api/public/v1/kyc/expiring?days=30&limit=100
scope requerido: read
200 -> { days, count, items: [ { subject_ref, kyc_level, expires_at, status } ] }
GET /api/public/v1/compliance/report?from=&to=
scope requerido: read
200 -> {
  period, generated_at, privacy_notice,
  totals: { records, by_status, by_level, by_country, by_jurisdiction_code, by_risk_bucket, by_provider },
  expiring_30d, gas_used_total,
  policy: { version, policy_hash, doc_hash, anchored_at, tx_hash },
  contract: { address, version, deploy_block }
}

Reporte agregado para auditoria: solo conteos y hashes, jamas datos personales.
POST /api/public/v1/kyc/screening
scope requerido: stamp
{ "subject_ref": "0xab…ab", "screening_hash": "0xcd…cd",
  "sanctions_result": 0, "pep_tier": 0, "aml_risk": 12, "screened_at": "2026-08-05T10:00:00Z" }
202 -> { anchor_id, subject_ref, job_type: "attest_screening", status: "queued" }

Solo hashes y codigos numericos: nombres, listas y coincidencias quedan en tu backend.
POST /api/public/v1/kyc/source-of-funds
scope requerido: stamp
{ "subject_ref": "0xab…ab", "source_of_funds_hash": "0xcd…cd", "aml_risk": 20 }
202 -> { anchor_id, job_type: "attest_source_of_funds", status: "queued" }
POST /api/public/v1/kyc/consent
scope requerido: stamp
{ "subject_ref": "0xab…ab", "consent_hash": "0xcd…cd", "consent_at": "2026-08-05T10:00:00Z" }
202 -> { anchor_id, job_type: "record_consent", status: "queued" }
POST /api/public/v1/kyc/review
scope requerido: stamp
{ "subject_ref": "0xab…ab", "next_review_at": "2027-01-01T00:00:00Z" }
202 -> { anchor_id, job_type: "schedule_review", status: "queued" }
POST /api/public/v1/kyc/evidence
scope requerido: stamp
{ "subject_ref": "0xab…ab", "evidence_hash": "0xcd…cd", "evidence_type_code": 10 }
202 -> { anchor_id, job_type: "attach_evidence", status: "queued" }

Los documentos siguen cifrados en tu infraestructura; on-chain viaja solo el hash.
POST /api/public/v1/kyc/travel-rule
scope requerido: stamp
{ "subject_ref": "0xab…ab", "travel_rule_hash": "0xcd…cd" }
202 -> { anchor_id, job_type: "attest_travel_rule", status: "queued" }
POST /api/public/v1/kyc/compliance-check
scope requerido: read
{ "subject_ref": "0xab…ab", "min_level": 2, "max_screening_age_days": 365, "max_aml_risk": 60 }
200 -> { compliant: false, reason_code: 8, reason: "screening vencido", contract, chain_id }

Codigos: 0 ok · 1 inexistente · 2 revocado · 3 congelado · 4 vencido
5 nivel insuficiente · 6 sancionado · 7 riesgo AML alto · 8 screening vencido · 9 revision vencida
POST /api/public/v1/kyc/erase
scope requerido: stamp
{ "subject_ref": "0xab…ab", "reason_code": 100 }
202 -> { anchor_id, job_type: "erase_subject", status: "queued" }

Derecho al olvido (GDPR art. 17): borra los hashes del sujeto y deja solo una lapida con fecha.
GET /api/public/v1/openapi.json
scope requerido: publico
200 -> especificacion OpenAPI 3.1 completa (importable en Postman / Insomnia / Swagger UI)
POST Modulo de cumplimiento · ejemplos completos
scope requerido: stamp/read
# 1) Subir de nivel tras revision reforzada
POST /api/public/v1/kyc/level
{ "subject_ref": "0xab…ab", "kyc_level": 3, "reason_code": 10 }
202 {
  "anchor_id": "6f6d0a0e-1f6c-4a1b-9a3d-6c1c0f2b7f11",
  "status": "level_change_queued",
  "new_level": 3
}

# 2) Congelar por alerta AML (no revoca, solo invalida temporalmente)
POST /api/public/v1/kyc/freeze
{ "subject_ref": "0xab…ab", "frozen": true, "reason_code": 42 }
202 { "anchor_id": "6f6d…7f11", "status": "freeze_queued" }
# Descongelar: { "frozen": false }  -> status: "unfreeze_queued"

# 3) Renovar vigencia
POST /api/public/v1/kyc/extend
{ "subject_ref": "0xab…ab", "expires_at": "2027-01-31T00:00:00.000Z" }
202 { "anchor_id": "6f6d…7f11", "status": "extend_queued", "expires_at": "2027-01-31T00:00:00.000Z" }

# 4) Cartera por vencer (solo referencias opacas)
GET /api/public/v1/kyc/expiring?days=30&limit=100
200 {
  "days": 30,
  "count": 1,
  "items": [ { "subject_ref": "0xab…ab", "kyc_level": 2, "expires_at": "2026-09-01T00:00:00.000Z", "status": "confirmed" } ]
}

# 5) Reporte agregado para auditoria
GET /api/public/v1/compliance/report?from=2026-07-06T00:00:00Z&to=2026-08-05T00:00:00Z
200 {
  "period": { "from": "2026-07-06T00:00:00.000Z", "to": "2026-08-05T00:00:00.000Z" },
  "generated_at": "2026-08-05T22:00:00.000Z",
  "privacy_notice": "Reporte agregado. No contiene datos personales: solo conteos, hashes opacos y codigos.",
  "totals": {
    "records": 128,
    "by_status": { "confirmed": 120, "pending": 5, "revoked": 3 },
    "by_level": { "1": 40, "2": 70, "3": 18 },
    "by_country": { "MX": 90, "US": 30, "desconocido": 8 },
    "by_jurisdiction_code": { "484": 90, "840": 30 },
    "by_risk_bucket": { "100": 88, "600": 40 },
    "by_provider": { "sumsub": 128 }
  },
  "expiring_30d": 12,
  "gas_used_total": 4210000,
  "policy": { "version": 3, "policy_hash": "0xcd…cd", "anchored_at": "2026-07-01T10:00:00.000Z" },
  "contract": { "address": "0x1234…", "version": "2", "deploy_block": 91234 }
}

Errores comunes: 401 firma/timestamp invalidos · 403 IP no permitida o key expirada ·
404 subject_ref desconocido · 422 payload invalido · 429 limite de tasa · 503 modo mantenimiento.
Ninguna respuesta incluye nombres, documentos, correos ni direcciones: solo hashes y codigos.

Webhooks hacia tu exchange

Se envian con reintentos exponenciales. Verifica la firma antes de procesar.

Cabeceras salientes:
  X-Webhook-Timestamp: 1717171717
  X-Webhook-Signature: hex hmac_sha256(webhook_secret, `${timestamp}.${body}`)
  X-Webhook-Event: kyc.anchor.confirmed

Eventos:
  kyc.anchor.confirmed   { subject_ref, payload_hash, tx_hash, block_number, chain_id, contract }
  kyc.anchor.failed      { subject_ref, error }
  kyc.anchor.revoked     { subject_ref, tx_hash }
  kyc.batch.committed    { batch_id, merkle_root, leaf_count, tx_hash }
  kyc.expired            { anchor_id, subject_ref, expires_at }
  kyc.level_changed      { anchor_id, subject_ref, previous_level, new_level, reason_code }
  kyc.frozen / kyc.unfrozen  { anchor_id, subject_ref, reason_code }
  policy.anchored        { policy_hash, doc_hash, tx_hash }
  system.alert           { code, message, severity }

Responde 2xx para confirmar la entrega.

Codigos de error

401 firma invalida / key inexistente o revocada
403 scope insuficiente
409 idempotency_key repetida con distinto payload
422 payload invalido (hashes deben ser 0x + 64 hex)
429 limite de peticiones por minuto excedido (ver cabeceras X-RateLimit-Limit / -Remaining / -Reset)
403 IP no incluida en la allowlist de la API key
503 sistema en modo mantenimiento (solo lecturas)
503 sin red o contrato activo

Cumplimiento sin datos personales

El contrato v2 guarda metadatos de compliance que no identifican a nadie: vigencia, hash de politica, hash de proveedor, jurisdiccion ISO numerica, banda de riesgo, codigo de motivo, nivel y revision. Tambien mantiene contadores agregados por nivel y jurisdiccion para auditorias.

Estados on-chain: valido | expirado | congelado | revocado
isValid(subject_ref)                -> bool
isValidAtLevel(subject_ref, nivel)  -> bool
statusOf(subject_ref)               -> (estado, nivel, expiresAt, riskBucket, jurisdiction, revision)
stats()                             -> totales agregados

Proceso periodico

Un cron llama cada minuto a /api/public/v1/cron/tick (protegido con secreto interno) para enviar transacciones, confirmar recibos, reintentar webhooks, marcar anclajes vencidos y vigilar el saldo de gas / la salud del RPC; cada hora cierra y ancla el lote Merkle.