# Twin Bridge — VASP Contract for hybrid VASPs

> This is the public spec for VASPs that implement Twin Bridge's standardised
> VASP interface. The same wire contract applies to both `we_built` and
> `self_pushed` hybrid VASPs; `self_pushed` partners use
> `adapter_class = "generic"`.

Base contract = **5 endpoints** (Phase 1). A hybrid VASP additionally
implements **2 Phase 2 endpoints**, for **7 endpoints total**. `kgs_only`
VASPs do not implement the Phase 2 endpoints. We only onboard hybrid VASPs.

## At a glance

You expose **seven HTTPS endpoints** when onboarded as a hybrid VASP. We call
them on every relevant transaction. Each request is signed with HMAC-SHA256
(canonical described below). You return JSON with the schemas in this document.

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/vasp/v1/quote`   | Return a fresh KGS↔USDT quote. Used by our Rate Engine. |
| `POST` | `/vasp/v1/qr`      | ON_RAMP: produce a payment QR for the end user to scan. |
| `POST` | `/vasp/v1/payout`  | OFF_RAMP: pay KGS to the end user via your banking rail. |
| `POST` | `/vasp/v1/send-usdt` | ON_RAMP: send USDT to the client's wallet after receiving KGS. **Hybrid / Phase 2 only.** |
| `POST` | `/vasp/v1/usdt-deposit-address` | OFF_RAMP: issue a USDT deposit address for the client. **Hybrid / Phase 2 only.** |
| `GET`  | `/vasp/v1/tx/{external_tx_id}` | Reconciliation polling. |
| `GET`  | `/vasp/v1/health`  | Liveness + supported pairs. |

Path prefix is fixed at `/vasp/v1/`. Base URL is whatever you give us (your
production / staging hosts) and we configure per-partner.

## Authentication

Every request from us carries:

```
Content-Type: application/json
X-API-Key:    <the key you issued to us during onboarding>
X-Timestamp:  <unix-seconds>
X-Signature:  <hex(hmac_sha256(your_secret, canonical))>
```

Canonical is built deterministically:

```
{timestamp}\n{METHOD}\n{path}\nsha256:{lower_hex(sha256(body))}
```

Empty body → empty `sha256:<hash_of_empty_string>` segment. Verify by
re-deriving the canonical from the incoming request and comparing to
`X-Signature`. Reject with HTTP 401 + `{"code":"BAD_SIGNATURE", ...}` on
mismatch.

Replay window: accept timestamps within ±300s of your local clock.

## Error responses

All non-2xx replies use the same envelope:

```json
{ "code": "INVALID_REQUEST", "message": "human-readable" }
```

`code` values we react to specifically:
- `KYC_REJECTED` — we mark the tx as FAILED with `failure_reason=kyc_rejected`.
- everything else → generic retry / surface to provider via standard webhooks.

## `POST /vasp/v1/quote`

Quote a price. We poll this through the Rate Engine for every transaction
plus periodically for scoring.

Request:
```json
{
  "pair":           "KGS/USDT",     // required, "KGS/USDT" on MVP
  "amount":         "1000",         // required, decimal string
  "direction":      "ON_RAMP",      // REQUIRED, "ON_RAMP" or "OFF_RAMP" — reject with 400 INVALID_REQUEST if missing
  "payment_method": "elqr"          // optional, defaults to first method you support
}
```

> **Why `direction` is required:** ON_RAMP and OFF_RAMP can have asymmetric
> rates (different spread depending on which side of the trade the customer
> sits on). Our Rate Engine always sends it; reject the request if it's
> missing rather than guessing — a missing field signals an integration
> bug on our side, better surfaced early.

Response 200:
```json
{
  "quote_id":      "your-quote-id",
  "rate":          "89.50",
  "fiat_amount":   "1000",
  "crypto_amount": "11.17",
  "fee":           "0",
  "expires_at":    "2026-05-22T12:00:00Z"
}
```

Quote should be valid for at least 5 minutes. We honour `expires_at` exactly.

## `POST /vasp/v1/qr` (ON_RAMP)

Produce a QR the end user scans in their banking app to send KGS to you.

Request:
```json
{
  "tx_id":          "<bare-uuid>",                 // our id (bare UUID, NO tb- prefix), also serves as your idempotency key
  "provider_slug":  "guardarian",                  // who originated this tx (Travel Rule / split)
  "amount":         "1000",
  "currency":       "KGS",
  "client_account": "user-internal-id",            // optional, may be empty
  "ttl_seconds":    300,                           // 0 = your default
  "kyc_share_token": "...",                        // Sumsub or similar
  "kyc_data": {                                     // present iff TB believes you need PII
    "full_name":    "...",
    "email":        "...",
    "date_of_birth":"YYYY-MM-DD",
    "country":      "KG",
    "phone":        "996555..."
  }
}
```

> **`tx_id` is a bare UUID.** Provider attribution is the separate
> `provider_slug` field, never embedded in `tx_id`. (The `tb-<slug>-<uuid>`
> prefix you may see elsewhere is internal to the legacy DantePay adapter and
> does not apply to this `self_pushed` / generic contract.)

Response 200:
```json
{
  "external_tx_id": "vasp-internal-id",       // optional but recommended — see below
  "data":           "00020126...",
  "image_url":      "https://you/qr/png/...",
  "expires_at":     "2026-05-22T12:05:00Z",
  "amount":         "1000",
  "currency":       "KGS"
}
```

> **About `external_tx_id`:** optional field. If you return it, we persist
> it in our `transactions.external_tx_id` and use it later as the lookup
> key for the webhook you send back. If you omit it, we fall back to our
> own `tx_id` — fine for symmetric mocks, but if your real internal
> bookkeeping has a different ID, you'll want to return that here so
> your webhook handler can resolve to your row by the same key. Common
> pattern: prefix our tx_id (e.g. `"kvsp-<our_tx_id>"`) so the link is
> obvious in your audit log.

After the user pays, you POST a webhook back to us:
```
POST {our_url}/internal/webhooks/{your_slug}
```
with the same HMAC scheme (yours, not ours) and a body matching whatever
shape you registered during onboarding — at minimum `{"external_tx_id":"...", "status":"COMPLETED"}`.

If you cannot push webhooks, declare `supports_webhook: false` in your config and we will poll `/vasp/v1/tx/...`.

## `POST /vasp/v1/payout` (OFF_RAMP)

We instruct you to pay KGS to the end user. Provider-side has already debited
their USDT internally.

Request:
```json
{
  "tx_id":           "<bare-uuid>",  // our id; bare UUID, no tb- prefix
  "provider_slug":   "guardarian",   // who originated this tx (attribution)
  "idempotency_key": "...",          // Stripe-style; see Idempotency-Key below
  "recipient_wallet":"",             // one of wallet / phone is set
  "recipient_phone": "996700...",
  "kgs_amount":      "1000",
  "kyc_share_token": "...",
  "kyc_data":        { ... }
}
```

Response 200:
```json
{
  "external_tx_id": "your-payout-id",
  "status":         "ACCEPTED",      // or EXECUTED / REJECTED
  "reason":         ""               // populated only on REJECTED
}
```

`ACCEPTED` is allowed — we will poll `/vasp/v1/tx/...` for terminal state.

### `Idempotency-Key` HTTP header — critical for money-safety

On **this endpoint only**, Twin Bridge sends an extra HTTP header in addition to
the auth headers:

```
Idempotency-Key: <same value as the body field idempotency_key>
```

- It is **not** part of the HMAC canonical — verifying the signature works
  exactly as on the other endpoints. Do not include it in your canonical
  re-derivation.
- You **must** deduplicate on it. If you receive a payout request whose
  `Idempotency-Key` you have already processed, return the **same**
  `external_tx_id` with HTTP 200 and **do not pay out again**.
- Why it matters: a payout is not safe to blindly retry. When our call to you
  ends ambiguously (timeout, 5xx, connection reset) we cannot tell whether the
  transfer started. We do not auto-retry the payout; instead the transaction
  goes to `UNKNOWN` and reconciliation polls `/vasp/v1/tx/...`. But if a retry
  ever does reach you (network replay, our operator action), the
  `Idempotency-Key` is the last line of defence against a **double payout**.
  Gateway-level dedup on this header catches the duplicate before any money
  moves.

The body `idempotency_key` carries the same value, so you can dedupe at either
layer (HTTP gateway or application). The header lets you reject duplicates
before parsing the body.

## `POST /vasp/v1/send-usdt` (ON_RAMP, hybrid / Phase 2 only)

After the end user has paid KGS to you, Twin Bridge instructs you to send USDT
to the client's wallet.

Request:
```json
{
  "tx_id":            "<bare-uuid>",
  "external_tx_id":   "vasp-internal-id",
  "wallet_address":   "TR...",
  "network":          "TRC20",
  "amount":           "11.17",
  "currency":         "USDT",
  "kyc_share_token":  "...",
  "kyc_data":         { ... }
}
```

Response 200:
```json
{
  "status":        "ACCEPTED",
  "vasp_tx_id":    "your-send-id",
  "on_chain_hash": "",
  "sent_at":       ""
}
```

`status` is either `ACCEPTED` or `SENT`. `SENT` means the on-chain transfer is
done; return `on_chain_hash` and, if available, `sent_at` as RFC3339.
`ACCEPTED` means you accepted the instruction but the transfer is not terminal
yet; we will poll `/vasp/v1/tx/{external_tx_id}`.

### `Idempotency-Key` HTTP header — critical for money-safety

On this endpoint, Twin Bridge sends:

```
Idempotency-Key: <same value as tx_id>
```

You **must** deduplicate on it. If you receive the same `Idempotency-Key`
again, return the same result and do not send USDT twice. When our call to you
ends ambiguously (timeout, 5xx, connection reset), we treat the transaction as
`DISPUTED` for manual handling rather than blindly retrying a money movement.

## `POST /vasp/v1/usdt-deposit-address` (OFF_RAMP, hybrid / Phase 2 only)

Twin Bridge asks you to issue a USDT deposit address for the client. The client
sends USDT there, then you later notify us with a `COMPLETED` webhook.

Request:
```json
{
  "tx_id":           "<bare-uuid>",
  "external_tx_id":  "vasp-internal-id",
  "amount":          "11.17",
  "currency":        "USDT",
  "network":         "TRC20",
  "ttl_seconds":     900,
  "kyc_share_token": "...",
  "kyc_data":        { ... }
}
```

Response 200:
```json
{
  "deposit_address": "TR...",
  "external_tx_id":  "your-deposit-id",
  "expires_at":      "2026-05-22T12:15:00Z",
  "memo":            ""
}
```

You **must** return `external_tx_id`; this is how we match your later
`COMPLETED` webhook to the transaction. `expires_at` is RFC3339. Use `memo`
only for networks that require an additional destination tag or memo.

## `GET /vasp/v1/tx/{external_tx_id}`

Reconciliation polling. We call this whenever a webhook was missed or you
declared `supports_webhook: false`.

Response 200:
```json
{ "external_tx_id": "...", "status": "PENDING" }    // or COMPLETED / FAILED / NOT_FOUND
```

## `GET /vasp/v1/health`

```json
{ "alive": true, "latency_ms": 12, "pairs": ["KGS/USDT"] }
```

Used by Rate Engine and Circuit Breaker. Return within 200ms; otherwise we
penalise your reliability score.

## Webhooks — VASP → Twin Bridge (reverse direction)

When your side reaches a new transaction state (the customer paid, the payout
was executed, the operation failed), push a webhook to us. This is the only
way we learn the outcome of an ON_RAMP — `POST /vasp/v1/qr` returns immediately
with a QR, but the actual payment happens later when the end user scans it.

### Endpoint

```
POST {twin_bridge_base_url}/internal/webhooks/{your_slug}
```

For sandbox the base URL is `https://twinbridge.asystem.ai`. Your `slug` is
the one we assigned to your partner row (e.g. `kiril-vsp`).

### Headers

The contract is **symmetric** with the one we use when calling you — same
canonical, same algorithm, just signed with **your** webhook secret rather
than the secret we use for outbound calls.

```
Content-Type: application/json
X-API-Key:    {your_slug}
X-Timestamp:  {unix-seconds}
X-Signature:  hex(hmac_sha256(your_webhook_secret, canonical))
```

Canonical:

```
{timestamp}\n{METHOD}\n{path}\nsha256:{lower_hex(sha256(body))}
```

`path` here is `/internal/webhooks/{your_slug}`, including the leading slash.

We accept timestamps within ±300 seconds of our clock. Replays outside that
window get HTTP 401 `WEBHOOK_INVALID_SIGNATURE`.

### Secret

`your_webhook_secret` is what we will configure on our side as
`partners.webhook_secret`. It is **separate** from the secret we use to sign
outbound calls to you (`partners.outbound_secret` / your "Secret" in the
admin form). Two different secrets in two directions — leaking one does not
compromise the other.

### Payload

Minimal required body for every event:

```json
{
  "external_tx_id": "string",
  "status":         "PAID" | "COMPLETED" | "FAILED"
}
```

| Field | Required | Description |
|---|---|---|
| `external_tx_id` | yes | The id you returned from `/vasp/v1/qr` or `/vasp/v1/payout`. This is how we look up the transaction on our side. |
| `status` | yes | One of the three values below. |

You may include additional fields (KGS amount actually received, timestamps,
internal references). We ignore them; they are useful for debugging from
the audit log.

### Status semantics

| status | Meaning | When to send |
|---|---|---|
| `PAID` | End user paid the KGS into your ELQR rail (ON_RAMP) — funds are now on your side, settlement to Provider pending. | Optional but recommended for ON_RAMP. If you skip it and go straight to `COMPLETED`, we collapse the chain. |
| `COMPLETED` | Terminal success. ON_RAMP: payment fully reconciled, you have the KGS, Provider can credit USDT to the customer. OFF_RAMP: you executed the KGS payout, customer received funds. | Always required. |
| `FAILED` | Terminal failure. Include a `failure_reason` field (see below) so we can tell the Provider what to show the customer. | Whenever the transaction will not complete: KYC rejection, insufficient liquidity, expired QR not paid, etc. |

#### `failure_reason` values we recognise

| `failure_reason` | When to use |
|---|---|
| `kyc_rejected` | KYC check failed on your side (sanctions, age, document). |
| `insufficient_liquidity` | You cannot fulfil at the quoted rate. |
| `qr_expired` | The ON_RAMP QR you returned timed out without payment. |
| `payout_rejected` | Bank/ELQR rail refused the OFF_RAMP transfer. |
| `internal_error` | Anything else. |

Any value we do not recognise is preserved as-is in our audit log but mapped
to `internal_error` for the Provider-facing webhook.

### State machine — order of webhooks

The transaction status on our side moves through:

```
INITIATED → PENDING_PAYMENT → PAID → PROCESSING → COMPLETED
                  │                           │
              CANCELLED                  FAILED
```

You don't need to drive every intermediate step. If you only send
`COMPLETED`, we walk the chain ourselves: `PENDING_PAYMENT → PAID →
PROCESSING → COMPLETED` in a single atomic step. Sending `PAID` first is
useful when there is a gap between "customer paid" and "funds settled" on
your side — it lets us show an in-progress state to the Provider.

What you should **not** do:

- Skip terminal status. If a transaction will not complete, send `FAILED`
  — otherwise we will keep polling `/vasp/v1/tx/{id}` until the QR expires
  and eventually escalate to DISPUTED.
- Send `COMPLETED` and then `FAILED` for the same `external_tx_id`. Once
  we accept a terminal status, the FSM rejects further transitions.

### Responses, retries, idempotency

We respond:

- `200 OK` — webhook accepted (or duplicate that we already processed; you
  can stop retrying).
- `401 WEBHOOK_INVALID_SIGNATURE` — HMAC failed or timestamp out of window.
- `400 INVALID_BODY` — missing `external_tx_id` or `status`.
- `404 NOT_FOUND` — no transaction with that `external_tx_id` on our side.
  Most likely the `/vasp/v1/qr` response was lost — re-send with the same
  `external_tx_id` once we re-call you. Do not invent new ids.
- `422` — the FSM rejected the transition (e.g. trying to move COMPLETED
  back to PENDING). Treat as terminal; stop retrying.
- `5xx` — our problem, please retry with exponential backoff.

We deduplicate on `(your_slug, X-Delivery-Id || external_tx_id, status,
sha256(body))`. Sending the same webhook twice is a no-op. Send an
optional `X-Delivery-Id` header if you have a stable per-event id —
otherwise we fall back to `external_tx_id`, which means we will accept at
most one webhook per status per transaction (which is what you want).

### Sandbox

Sandbox and production share the same scheme — only the `webhook_secret`
and `base_url` differ. While you are integrating, point your webhook
sender at `https://twinbridge.asystem.ai/internal/webhooks/{your_slug}`
with the sandbox secret we issue.

Rate limit on the webhook endpoint: **no per-partner limit** in sandbox.
In production we may apply tier-based quotas (≥100 req/min on Starter).

## Serverless deployments — path mapping

If you host your endpoints on **Supabase Edge Functions**, **Vercel**,
**Cloudflare Workers**, **AWS Lambda + API Gateway**, or any platform that
**strips the route prefix** before your handler sees the request, your
runtime sees a shorter path than the one in our canonical. The HMAC will
not match.

Example: your function URL is
`https://yourproject.supabase.co/functions/v1/vasp-quote`. Inside the
Edge runtime your code sees `req.url.pathname === "/vasp-quote"` — the
`/functions/v1/` prefix is stripped. Our canonical is built with the
full `/vasp/v1/quote` by default, your verifier rebuilds it with
`/vasp-quote`, signatures diverge, 401.

**Solution:** during onboarding, tell us your actual path layout. We'll
configure `partners.config.path_overrides` so our canonical matches what
your runtime actually sees. Pattern:

```json
{
  "path_overrides": {
    "/vasp/v1/quote":  "/vasp-quote",
    "/vasp/v1/qr":     "/vasp-qr",
    "/vasp/v1/payout": "/vasp-payout",
    "/vasp/v1/send-usdt": "/vasp-send-usdt",
    "/vasp/v1/usdt-deposit-address": "/vasp-usdt-deposit-address",
    "/vasp/v1/tx/":    "/vasp-tx-status/",
    "/vasp/v1/health": "/vasp-health"
  }
}
```

Key ending with `/` is a prefix-rewrite (used for `/vasp/v1/tx/{id}`).
Exact keys are matched as-is. If your platform preserves the full path
(traditional dedicated server, Caddy/nginx reverse proxy, Express on a
VM), leave this empty — defaults work.

## Two secrets, not one

Confusing on first onboard, so up-front: there are **two HMAC secrets**,
one per direction.

| Secret | Stored at TB | Used to sign | Issued by |
|---|---|---|---|
| `partners.outbound_secret` (your "Secret" in admin form) | `partners.config.secret` | Our requests **to you** | **You** issue and send to us |
| `partners.webhook_secret` | `partners.webhook_secret` | Your webhooks **to us** | **We** generate and send to you |

Leak of one direction does not compromise the other. During onboarding:

1. You generate the secret for inbound (TB→VASP) traffic, send it to us
2. We generate the webhook secret for outbound (VASP→TB) traffic, send
   it to you
3. Both sides save in their respective vaults

## Onboarding checklist

1. Implement the seven hybrid endpoints + a webhook sender (if you can push).
2. Send us: base URL, the API key you issued to us, your inbound HMAC
   secret. If you're on a serverless platform that strips route prefixes,
   include your actual path map (see "Serverless deployments" above).
3. We register your partner row (`adapter_class='generic'`,
   `integration_mode='self_pushed'`), generate your outbound webhook
   secret and return it, restart the API container — your endpoints are
   now part of Rate Engine routing.
4. Sandbox: we test against your staging URL with `X-Sandbox: true`.
   Stand `https://twinbridge.asystem.ai` carries `mock-self-pushed-vasp`
   as a reference implementation (`simulator/mock-self-pushed-vasp/main.go`).

## Reference implementation

See `simulator/mock-self-pushed-vasp/main.go` in our repo (≈170 lines of Go).
It implements every endpoint with stub responses and the same HMAC verifier
you need to ship.
