
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/v1Playground 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.
Estado del servicio · scope
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
{
"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" }{ "subject_ref": "0x…", "reason_hash": "0x…" }
202 -> { "anchor_id": "uuid", "status": "revoke_queued" }200 -> { "subject_ref": "0x…", "anchors": [ { status, tx_hash, block_number, batch_id, … } ] }200 -> { leaf_hash, leaf_index, proof: ["0x…"], batch: { merkle_root, tx_hash, … } }{ "subject_ref": "0x…", "payload_hash": "0x…" }
200 -> { "valid": true, "stamped_at": 1717171717, "contract": "0x…", "chain_id": 1337 }200 -> { merkle_root, leaf_count, status, tx_hash, block_number, period_start, period_end }200 -> { status: "ok", pending_anchors: 3, chain: { connected: true, chain_id, block } }{ "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 } ] }{ "subject_refs": ["0x…", "0x…"] } // maximo 500
200 -> { "results": { "0x…": { status, tx_hash, block_number, batch_id } } }200 -> { total, confirmed, pending, failed, revoked, last_24h, last_7d, batches: { open, committed } }200 -> { contract: { address, version, status }, network: { chain_id, name, explorer_url }, abi?: [...] }{ "subject_ref": "0x…", "kyc_level": 3, "reason_code": 10 }
202 -> { anchor_id, status: "level_change_queued", new_level: 3 }{ "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).{ "subject_ref": "0x…", "expires_at": "2027-08-02T00:00:00Z" }
202 -> { anchor_id, status: "extend_queued", expires_at }200 -> { days, count, items: [ { subject_ref, kyc_level, expires_at, status } ] }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.{ "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.{ "subject_ref": "0xab…ab", "source_of_funds_hash": "0xcd…cd", "aml_risk": 20 }
202 -> { anchor_id, job_type: "attest_source_of_funds", status: "queued" }{ "subject_ref": "0xab…ab", "consent_hash": "0xcd…cd", "consent_at": "2026-08-05T10:00:00Z" }
202 -> { anchor_id, job_type: "record_consent", status: "queued" }{ "subject_ref": "0xab…ab", "next_review_at": "2027-01-01T00:00:00Z" }
202 -> { anchor_id, job_type: "schedule_review", status: "queued" }{ "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.{ "subject_ref": "0xab…ab", "travel_rule_hash": "0xcd…cd" }
202 -> { anchor_id, job_type: "attest_travel_rule", status: "queued" }{ "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{ "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.200 -> especificacion OpenAPI 3.1 completa (importable en Postman / Insomnia / Swagger UI)# 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 activoCumplimiento 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 agregadosProceso 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.