Habilitá SARLAFT
El flow default ya corre SARLAFT, pero seguro querés tus propias reglas (PEP, umbrales por país, etc.).
Quickstart →En 15 minutos vas a ejecutar un onboarding KYC completo contra el sandbox de
Digital Jungle, usando datos de prueba. Al final el subject queda en
status=approved y recibís un evento subject.verified por webhook.
@bjungle/sdk (TypeScript)Para integraciones nuevas usá el SDK oficial: tipado completo, retries con
backoff, Idempotency-Key automático en POSTs y errores mapeados (401,
402, 429, etc.) a clases tipadas.
npm install @bjungle/sdkimport { BjungleClient } from '@bjungle/sdk';
const dj = new BjungleClient({ apiKey: process.env.DJ_API_KEY!, // 'dj_test_...' del paso 1 baseUrl: 'https://api.qa.bjungle.net',});A lo largo del quickstart cada paso muestra primero la versión SDK y abajo
la equivalente con cURL/fetch para quienes integran en otro lenguaje.
| Endpoint | Método | Plano | Costo (sandbox) |
|---|---|---|---|
POST /signup | Tenant nuevo | Público | 0 |
POST /v1/flows/seed-default | Crea + publica onboarding default | Tenant | 0 |
POST /v1/flows/sessions | Inicia sesión | Tenant | 0 |
POST /v1/kyc/subjects/{id}/presign-upload | URL presigned S3 | Tenant | 0 |
PUT <s3_upload_url> | Subida directa a S3 | Público (firmado) | 0 |
POST /v1/flows/sessions/{id}/advance | Avanza paso | Tenant | acumula 80 al face_match |
GET /v1/flows/sessions/{id} | Lee estado final | Tenant | 0 |
Total ~5 llamadas + 2 PUTs a S3. En producción esto cobra 80 Açaí (60 % menos si se reusa wallet existente — ver Wallet SSI).
Andá a /registro?plan=sandbox y completá el formulario.
La respuesta incluye plaintext api_key y webhook_secret — copialos a un
secret manager o .env ahora, no se vuelven a mostrar.
Alternativa por API:
curl -X POST https://api.qa.bjungle.net/signup \ -H "Content-Type: application/json" \ -d '{ "plan": "sandbox", "slug": "tu-fintech", "display_name":"Tu Fintech", "owner_email": "dev@tufintech.co" }'const res = await fetch('https://api.qa.bjungle.net/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plan: 'sandbox', slug: 'tu-fintech', display_name: 'Tu Fintech', owner_email: 'dev@tufintech.co', }),});const { api_key, webhook_secret, tenant_id } = await res.json();console.log({ api_key, webhook_secret, tenant_id });package main
import ( "bytes" "encoding/json" "fmt" "net/http")
func main() { body, _ := json.Marshal(map[string]any{ "plan": "sandbox", "slug": "tu-fintech", "display_name": "Tu Fintech", "owner_email": "dev@tufintech.co", }) res, _ := http.Post( "https://api.qa.bjungle.net/signup", "application/json", bytes.NewReader(body), ) defer res.Body.Close() var out struct { APIKey string `json:"api_key"` WebhookSecret string `json:"webhook_secret"` TenantID string `json:"tenant_id"` } _ = json.NewDecoder(res.Body).Decode(&out) fmt.Printf("%+v\n", out)}Exportalas como env vars para los siguientes pasos:
export DJ_BASE=https://api.qa.bjungle.netexport DJ_API_KEY=dj_test_...export DJ_WEBHOOK_SECRET=...bmonkey ofrece un atajo idempotente que crea y publica el flow cashpaya-style (consent → document → face_match → sarlaft) en una sola llamada:
const flow = await dj.bmonkey.flows.seedDefault();// { id, code: 'default', type: 'onboarding', status: 'published', version: 1 }curl -X POST "$DJ_BASE/v1/flows/seed-default" \ -H "X-API-Key: $DJ_API_KEY"const res = await fetch(`${DJ_BASE}/v1/flows/seed-default`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY },});const flow = await res.json(); // {id, code:'default', type:'onboarding', status:'published', ...}req, _ := http.NewRequest("POST", os.Getenv("DJ_BASE")+"/v1/flows/seed-default", nil)req.Header.Set("X-API-Key", os.Getenv("DJ_API_KEY"))res, _ := http.DefaultClient.Do(req)defer res.Body.Close()Respuesta 201:
{ "id": "f1b2c3d4-…", "code": "default", "type": "onboarding", "status": "published", "version": 1}const session = await dj.bmonkey.flows.createSession({ type: 'onboarding', code: 'default', subject_ref: 'user-quickstart-001', context: { document_type: 'CC', document_number: '1023456789', country: 'CO', email: 'camila@tufintech.co', phone: '+573001234567', },});// { id, token, subject_id, current_step, status, ... }curl -X POST "$DJ_BASE/v1/flows/sessions" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "onboarding", "code": "default", "external_ref": "user-quickstart-001", "document_type": "CC", "document_number": "1023456789", "country": "CO", "email": "camila@tufintech.co", "phone": "+573001234567" }'const res = await fetch(`${DJ_BASE}/v1/flows/sessions`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'onboarding', code: 'default', external_ref: 'user-quickstart-001', document_type: 'CC', document_number: '1023456789', country: 'CO', email: 'camila@tufintech.co', phone: '+573001234567', }),});const session = await res.json();// { id, session_token, subject_id, current_step, status, ... }body, _ := json.Marshal(map[string]any{ "type": "onboarding", "code": "default", "external_ref": "user-quickstart-001", "document_type": "CC", "document_number": "1023456789", "country": "CO", "email": "camila@tufintech.co", "phone": "+573001234567",})req, _ := http.NewRequest("POST", base+"/v1/flows/sessions", bytes.NewReader(body))req.Header.Set("X-API-Key", apiKey)req.Header.Set("Content-Type", "application/json")res, _ := http.DefaultClient.Do(req)defer res.Body.Close()Capturá session.id, session.session_token y session.subject_id — los vas a
usar en los siguientes pasos.
bmonkey usa uploads directos a S3 con presigned URLs — los bytes nunca pasan por nuestro backend. Para cada blob necesitás:
POST /v1/kyc/subjects/{subject_id}/presign-upload.PUT al upload_url con el body crudo del archivo.stored_uri (queda referenciado automáticamente en la sesión).async function uploadKycAsset(subjectId: string, purpose: 'doc_front' | 'doc_back' | 'selfie', file: Blob) { // 1. presign const { url, key } = await dj.bmonkey.subjects.presignUpload(subjectId, { purpose, content_type: file.type || 'image/jpeg', });
// 2. PUT directo a S3 (fuera del SDK — el SDK firma el presign, no el upload) await fetch(url, { method: 'PUT', body: file });
return key; // stored_uri equivalente}
const docFrontURI = await uploadKycAsset(session.subject_id, 'doc_front', docFrontFile);const selfieURI = await uploadKycAsset(session.subject_id, 'selfie', selfieFile);# Documento frontalPRESIGN=$(curl -s -X POST "$DJ_BASE/v1/kyc/subjects/$SUBJECT_ID/presign-upload" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{"kind":"doc_front"}')UPLOAD_URL=$(echo "$PRESIGN" | jq -r .upload_url)DOC_FRONT_URI=$(echo "$PRESIGN" | jq -r .stored_uri)
curl -X PUT "$UPLOAD_URL" --data-binary @cedula-frontal.jpg
# SelfiePRESIGN=$(curl -s -X POST "$DJ_BASE/v1/kyc/subjects/$SUBJECT_ID/presign-upload" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{"kind":"selfie"}')UPLOAD_URL=$(echo "$PRESIGN" | jq -r .upload_url)SELFIE_URI=$(echo "$PRESIGN" | jq -r .stored_uri)
curl -X PUT "$UPLOAD_URL" --data-binary @selfie.jpgasync function uploadKycAsset(subjectId, kind, file) { // 1. presign const presignRes = await fetch( `${DJ_BASE}/v1/kyc/subjects/${subjectId}/presign-upload`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ kind }), }, ); const { upload_url, stored_uri } = await presignRes.json();
// 2. PUT directo a S3 await fetch(upload_url, { method: 'PUT', body: file });
return stored_uri;}
const docFrontURI = await uploadKycAsset(subject_id, 'doc_front', docFrontFile);const selfieURI = await uploadKycAsset(subject_id, 'selfie', selfieFile);func uploadKycAsset(ctx context.Context, base, key, subjectID, kind string, r io.Reader) (string, error) { body, _ := json.Marshal(map[string]string{"kind": kind}) req, _ := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/v1/kyc/subjects/%s/presign-upload", base, subjectID), bytes.NewReader(body)) req.Header.Set("X-API-Key", key) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer res.Body.Close() var out struct { UploadURL string `json:"upload_url"` StoredURI string `json:"stored_uri"` } if err := json.NewDecoder(res.Body).Decode(&out); err != nil { return "", err }
putReq, _ := http.NewRequestWithContext(ctx, "PUT", out.UploadURL, r) if _, err := http.DefaultClient.Do(putReq); err != nil { return "", err } return out.StoredURI, nil}GET /v1/flows/sessions/{id} te dice qué step viene. Cada advance consume
el input correspondiente y mueve el cursor. El orden del flow default es:
| # | step_type | Input esperado |
|---|---|---|
| 1 | consent | { "accepted": true } |
| 2 | document_upload | { "doc_front_uri": "...", "doc_back_uri": "..." } |
| 3 | face_match | { "selfie_uri": "...", "liveness_mode": "basic" } |
| 4 | sarlaft | {} (corre automático contra bhawk) |
await dj.bmonkey.flows.advance(session.id, { input: { accepted: true } });await dj.bmonkey.flows.advance(session.id, { input: { doc_front_uri: docFrontURI } });await dj.bmonkey.flows.advance(session.id, { input: { selfie_uri: selfieURI, liveness_mode: 'basic' },});const final = await dj.bmonkey.flows.advance(session.id, { input: {} });
console.log(final.status, final.last_outcome);El SDK ya maneja 429/503 con retry + backoff exponencial, así que no
necesitás tu propio while para reintentar.
# Step 1 — consentcurl -X POST "$DJ_BASE/v1/flows/sessions/$SESSION_ID/advance" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input":{"accepted":true}}'
# Step 2 — documentocurl -X POST "$DJ_BASE/v1/flows/sessions/$SESSION_ID/advance" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"input\":{\"doc_front_uri\":\"$DOC_FRONT_URI\"}}"
# Step 3 — biometría (dispara el débito Açaí + corre el pipeline)curl -X POST "$DJ_BASE/v1/flows/sessions/$SESSION_ID/advance" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"input\":{\"selfie_uri\":\"$SELFIE_URI\",\"liveness_mode\":\"basic\"}}"
# Step 4 — SARLAFT (automático)curl -X POST "$DJ_BASE/v1/flows/sessions/$SESSION_ID/advance" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input":{}}'async function advance(sessionId, input) { const res = await fetch( `${DJ_BASE}/v1/flows/sessions/${sessionId}/advance`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ input }), }, ); if (!res.ok) throw new Error(`advance failed: ${await res.text()}`); return res.json();}
await advance(sessionId, { accepted: true });await advance(sessionId, { doc_front_uri: docFrontURI });await advance(sessionId, { selfie_uri: selfieURI, liveness_mode: 'basic' });const final = await advance(sessionId, {});
console.log(final.status, final.last_outcome);func advance(ctx context.Context, base, key, sessionID string, input map[string]any) (*Session, error) { body, _ := json.Marshal(map[string]any{"input": input}) req, _ := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/v1/flows/sessions/%s/advance", base, sessionID), bytes.NewReader(body)) req.Header.Set("X-API-Key", key) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode >= 300 { b, _ := io.ReadAll(res.Body) return nil, fmt.Errorf("advance %d: %s", res.StatusCode, b) } var s Session return &s, json.NewDecoder(res.Body).Decode(&s)}Cada respuesta trae:
{ "id": "...", "status": "in_progress", "current_step": "face_match", "last_outcome": "advance", "context": { /* respuestas acumuladas */ }}status final es completed (todos los steps OK), failed (alguno rechazó),
manual_review (SARLAFT pidió review).
const final = await dj.bmonkey.flows.getSession(session.id);console.log(final.status === 'completed' ? 'KYC OK' : final.last_outcome);curl -s "$DJ_BASE/v1/flows/sessions/$SESSION_ID" \ -H "X-API-Key: $DJ_API_KEY" | jqconst final = await fetch(`${DJ_BASE}/v1/flows/sessions/${sessionId}`, { headers: { 'X-API-Key': DJ_API_KEY },}).then(r => r.json());
console.log(final.status === 'completed' ? 'KYC OK' : final.last_outcome);Respuesta:
{ "id": "...", "subject_id": "...", "status": "completed", "current_step": null, "last_outcome": "advance", "completed_at": "2026-06-05T14:00:00Z"}En paralelo recibís el webhook subject.verified (ver siguiente sección).
subject.verifiedRegistrá tu endpoint con PUT /tenants/{id}/webhook-endpoints/bmonkey.
Payload de ejemplo:
{ "event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "module": "bmonkey", "event_type": "subject.verified", "occurred_at":"2026-06-05T14:00:00Z", "payload": { "subject_id": "550e8400-e29b-41d4-a716-446655440000", "external_ref": "user-quickstart-001", "kyc_status": "approved", "document_type": "CC", "document_country": "CO", "subject_age_years": 28, "restrictive_list_hits": [] }}Verificá la firma HMAC antes de procesar:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(rawBody, signatureHeader, secret) { const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const provided = signatureHeader.replace(/^sha256=/, ''); return ( expected.length === provided.length && timingSafeEqual(Buffer.from(expected), Buffer.from(provided)) );}func verify(body []byte, sigHeader, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) provided := strings.TrimPrefix(sigHeader, "sha256=") return hmac.Equal([]byte(expected), []byte(provided))}import hmac, hashlib
def verify(raw_body: bytes, sig_header: str, secret: bytes) -> bool: expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest() provided = sig_header.removeprefix("sha256=") return hmac.compare_digest(expected, provided)Habilitá SARLAFT
El flow default ya corre SARLAFT, pero seguro querés tus propias reglas (PEP, umbrales por país, etc.).
Quickstart →Firma electrónica
Sellá contratos PDF con KMS hash + auditoría.
Quickstart →Wallet SSI · reuso de KYC
Cuando otro tenant ya verificó al usuario, podés reusar con consent y pagar 60 % menos.
Concepto →OIDC Sign-in with bjungle
Authorization Code + PKCE contra bmonkey como IdP.
Concepto →El SDK mapea cada non-2xx a una clase de error. Capturalas en lugar de
chequear status numérico:
import { UnauthorizedError, InsufficientAcaiError, UnprocessableEntityError, RateLimitError, BjungleApiError,} from '@bjungle/sdk';
try { await dj.bmonkey.flows.advance(session.id, { input: { selfie_uri: selfieURI } });} catch (err) { if (err instanceof InsufficientAcaiError) { // 402 — balance agotado. Cargá créditos en /precios. } else if (err instanceof UnprocessableEntityError) { // 422 — KYC rechazado (liveness/face-match falló). Ver err.problem. } else if (err instanceof UnauthorizedError) { // 401 — rotá la API key. } else if (err instanceof RateLimitError) { // 429 — el SDK ya reintentó hasta maxRetries. } else if (err instanceof BjungleApiError) { console.error(err.status, err.problem); // RFC 9457 problem+json } else { throw err; }}| Síntoma | Causa típica | Fix |
|---|---|---|
401 invalid-api-key | Header X-API-Key faltante o key incorrecta | Verificá que copiaste el plaintext, no el hash |
402 insufficient-acai | Balance agotado (sandbox: testeo muy alto) | Cargá créditos en /precios |
409 state-conflict en seed-default | Ya existe flow onboarding default | Ignorá — significa que estás listo |
400 en advance con input vacío | El step actual exige input (revisá current_step) | Consultá la tabla de la sección Paso 5 |
422 kyc-rejected | Liveness o face-match falló | Subí selfie/documento con mejor calidad, o ver rejection_reason |
429 rate-limit-exceeded | Más de 120 req/min | Esperá al X-RateLimit-Reset (el SDK ya reintenta hasta maxRetries) |
| Webhook no llega | Tu endpoint no devuelve 2xx | Probá manual con curl + ver delivery en /webhook-deliveries |