> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ripio.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> The two requests your endpoint receives for the widget's buy and sell flow, how to verify the signature Ripio puts on them, and how to sign your approval response.

The widget notifies your backend of two things: a **buy** asks for your approval before it executes, and every operation reports its **result** once it's settled. Both are `POST` requests to the webhook URL configured on your account.

Signatures go both ways:

* **Ripio → you.** Every request is signed with HMAC-SHA256, so you can check it came from Ripio.
* **You → Ripio.** Your answer to an approval request is signed with your own Ed25519 key, so an approval can be attributed to you and only you.

<Note>
  This is a separate contract from the [Crypto as a Service webhooks](/crypto-as-a-service/webhooks/introduction) used by the core API — the widget's events use their own envelope and their own signatures, documented below.
</Note>

### Envelope

Every message shares the same envelope:

```json theme={null}
{
  "schema_version": "2026-09-01",
  "message_type": "transaction_approval_request",
  "event_id": "0f1c9a3e-5b7d-4c21-9f0a-6d2e8b41c7a5",
  "timestamp": "2026-09-28T14:03:11.482913Z",
  "message": { "...": "..." }
}
```

| Key | Type | Description |
| - | - | - |
| `schema_version` | *string* | Version of this contract. Currently `2026-09-01`. |
| `message_type` | *string* | `transaction_approval_request` or `transaction_result`. |
| `event_id` | *string* | Unique identifier of this delivery. A retry of the same `transaction_result` carries the same `event_id` — key your deduplication on it. |
| `timestamp` | *string* | RFC 3339 datetime, UTC (`Z` suffix), of when the message was issued. |
| `message` | *object* | The event's own payload, described below. |

<Warning>
  Reject any message whose `timestamp` is more than **5 minutes** old. The timestamp is part of the signed body, so it can't be rewritten without breaking the signature — checking it is what stops a captured request from being replayed later.
</Warning>

### Verifying the signature

Every request carries an `X-Ripio-Signature-256` header: an HMAC-SHA256 signature of the raw request body, computed with the signing key issued alongside your `client_id`/`client_secret`.

```
X-Ripio-Signature-256: sha256=<hex-encoded HMAC>
```

The signature covers the **exact bytes** of the body as received (compact JSON, no whitespace). Verify it over the raw body before parsing it — re-serializing the parsed JSON won't necessarily give you back the same bytes.

**Python**

```python theme={null}
import hashlib
import hmac

def verify_signature(raw_body: bytes, signature_header: str, signing_key: str) -> bool:
    expected = "sha256=" + hmac.new(
        signing_key.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

**Node.js**

```javascript theme={null}
const crypto = require("crypto");

function verifySignature(rawBody, signatureHeader, signingKey) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", signingKey).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

<Warning>
  Always compare signatures with a constant-time comparison (`hmac.compare_digest`, `crypto.timingSafeEqual`) — a naive `==` leaks timing information an attacker can use to forge a signature byte by byte.
</Warning>

### `transaction_approval_request`

Sent before a **buy** executes, only if your account has transaction approval enabled. Ripio makes a **single attempt** with a **5-second timeout** and does not follow redirects. If your endpoint doesn't respond in time, or responds with anything other than a valid, signed approval, the operation does not execute — this request is fail-closed.

```json theme={null}
{
  "schema_version": "2026-09-01",
  "message_type": "transaction_approval_request",
  "event_id": "0f1c9a3e-5b7d-4c21-9f0a-6d2e8b41c7a5",
  "timestamp": "2026-09-28T14:03:11.482913Z",
  "message": {
    "id": "df9d41d8-ac80-5125-9738-ce1fcb8dc523",
    "quote_id": "cf6c57ea-03a1-41dc-99e8-b91cc1b7333e",
    "external_ref": "00010203-0405-0607-0809-0a0b0c0d0e0f",
    "op_type": "BUY",
    "amount": "1500",
    "amount_currency": "fiat",
    "currency": "ARS"
  }
}
```

| Key | Type | Description |
| - | - | - |
| `id` | *string* | Identifier of the operation. Deterministic: a retry of the same quote carries the same `id`, and the `transaction_result` that follows carries it too — use it to correlate the two. |
| `quote_id` | *string* | The quote the user accepted. |
| `external_ref` | *string* | The user's identifier, as you provided it on [`POST /auth`](/crypto-as-a-service/widget/get-started/authentication). |
| `op_type` | *string* | Always `BUY` — a sell doesn't need approval, since Ripio holds the custody of the crypto being sold. |
| `amount` | *string* | The amount the user entered, in the unit given by `amount_currency`. |
| `amount_currency` | *string* | `fiat` or `crypto` — which side of the operation `amount` is expressed in. The user can type the amount in either. |
| `currency` | *string* | Your account's fiat currency (ISO 4217, e.g. `ARS`). |

<Warning>
  Don't assume `amount` is fiat: when `amount_currency` is `crypto`, `amount: "0.01"` means 0.01 of the crypto being bought, not one cent.
</Warning>

#### Responding

To approve, respond `HTTP 200` with a JSON body that includes the `event_id` of the request, and sign that body (see [Signing your response](#signing-your-response)):

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json
X-Partner-Signature-Ed25519: ed25519=<hex-encoded Ed25519 signature of the body>

{"approved":true,"event_id":"0f1c9a3e-5b7d-4c21-9f0a-6d2e8b41c7a5"}
```

An operation is approved only when **all** of these hold:

1. The status is `200`.
2. The body is a JSON object with `"approved": true` — the boolean. `"true"`, `1` or `"yes"` are treated as a denial.
3. The body's `event_id` equals the `event_id` of the request.
4. `X-Partner-Signature-Ed25519` carries a valid signature of the body under the public key you registered with Ripio.

A status other than `200`, or a validly signed response whose `approved` isn't `true`, **denies** the operation, and the user is told it wasn't approved.

<Warning>
  A missing or invalid signature, or an `event_id` that doesn't match the request, is **not** read as a denial: the operation doesn't execute, and the widget gets error `50007` ("could not be validated right now"). That keeps "you said no" distinguishable from "someone answered who couldn't prove to be you" — if you see `50007`, check your signing key and that you echo the right `event_id`.
</Warning>

#### Signing your response

Your approval response is signed with an **Ed25519** key pair that you generate and hold. You keep the private key; you hand the public key to Ripio during onboarding. Since only you hold the private key, a valid signature proves the approval came from your systems — an HMAC with a shared secret couldn't prove that, because Ripio holds the same secret.

Including the request's `event_id` in the signed body is what binds each signature to a single operation: without it, every `{"approved": true}` would be the same bytes and the same signature, reusable for any other purchase.

**1. Generate the key pair**

```bash theme={null}
openssl genpkey -algorithm ed25519 -out partner_response_private.pem
openssl pkey -in partner_response_private.pem -pubout -out partner_response_public.pem
```

This needs OpenSSL 1.1.1 or later — the LibreSSL build that ships with macOS doesn't support Ed25519; use Homebrew's `openssl` or generate the pair with your language's crypto library.

Send `partner_response_public.pem` (a PEM `PUBLIC KEY`) to your Ripio contact. Keep the private key in your secrets manager — never share it. A public key is required before transaction approval can be enabled on your account.

**2. Sign the exact bytes you send**

Serialize the response body **once**, sign those bytes, and send those same bytes. If your framework re-serializes the object after you sign it (different key order, added whitespace), the signature won't match.

**Python** (`cryptography`)

```python theme={null}
import json
from cryptography.hazmat.primitives.serialization import load_pem_private_key

with open("partner_response_private.pem", "rb") as f:
    private_key = load_pem_private_key(f.read(), password=None)

def approval_response(event_id: str, approved: bool) -> tuple[bytes, dict]:
    body = json.dumps(
        {"approved": approved, "event_id": event_id}, separators=(",", ":")
    ).encode("utf-8")
    signature = private_key.sign(body).hex()
    headers = {
        "Content-Type": "application/json",
        "X-Partner-Signature-Ed25519": f"ed25519={signature}",
    }
    return body, headers  # send `body` as the raw response content
```

**Node.js**

```javascript theme={null}
const crypto = require("crypto");
const fs = require("fs");

const privateKey = crypto.createPrivateKey(
  fs.readFileSync("partner_response_private.pem")
);

function approvalResponse(eventId, approved) {
  const body = Buffer.from(JSON.stringify({ approved, event_id: eventId }));
  const signature = crypto.sign(null, body, privateKey).toString("hex");
  return {
    body, // send these exact bytes, e.g. res.end(body)
    headers: {
      "Content-Type": "application/json",
      "X-Partner-Signature-Ed25519": `ed25519=${signature}`,
    },
  };
}
```

**3. Test against the reference vector**

Use this throwaway test key pair — it is not valid for any account — to confirm your implementation signs the way Ripio verifies. Signing the body below with the private key must produce exactly this signature (Ed25519 is deterministic):

```text theme={null}
Private key (test only):
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIM5+nJhBYxpo/mnya0QQZwhyjWhkobYDCvkz2RGHbS7l
-----END PRIVATE KEY-----

Public key (test only):
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAA+fXvBisuIQDioOnAfEh5iFUgtpug7fmfTTSW2DOcik=
-----END PUBLIC KEY-----

Body (exact bytes):
{"approved":true,"event_id":"11111111-1111-1111-1111-111111111111"}

Expected header:
X-Partner-Signature-Ed25519: ed25519=6add92a4d8c848718f440b3addcbb4edfbebf2cd4d3f1f70c0302351288c02057747f55568bfbd47b650170e666e6adbe3cf1ec4f145fde0c9bb7a145407ab09
```

### `transaction_result`

Tells you how an operation — buy or sell — ended, so you can update your own record of the user's balance and release anything you reserved when approving it. It is sent to every account with a webhook URL, whether or not transaction approval is enabled.

Each operation gets **at most one** result, identified by `message.id`, with one of two statuses:

* **`COMPLETED`** — the trade executed.
* **`CANCELLED`** — the operation did not execute and never will. It is final: once an operation is cancelled, Ripio refuses any further attempt on it, so a `CANCELLED` is never followed by a `COMPLETED` for the same `id`. If the user tries again, the widget asks for a new quote, which starts a new operation with a new `id`.

It is delivered **at least once**: any `2xx` response counts as received; anything else, or no response, is retried up to 3 times with exponential backoff (up to 60 seconds between attempts). A retry carries the same `event_id` — use it to discard duplicates. Your response to this message doesn't need to be signed.

**Completed:**

```json theme={null}
{
  "schema_version": "2026-09-01",
  "message_type": "transaction_result",
  "event_id": "57b526a5-092b-4d61-a394-b80e4bad5fcf",
  "timestamp": "2026-09-28T14:03:13.107455Z",
  "message": {
    "id": "df9d41d8-ac80-5125-9738-ce1fcb8dc523",
    "quote_id": "cf6c57ea-03a1-41dc-99e8-b91cc1b7333e",
    "external_ref": "00010203-0405-0607-0809-0a0b0c0d0e0f",
    "op_type": "BUY",
    "amount": "1500",
    "amount_currency": "fiat",
    "currency": "ARS",
    "fiat_amount": "1500.00",
    "asset": "BTC",
    "status": "COMPLETED",
    "txn_id": "8a3f2c1e-4b5d-4e6f-9a0b-1c2d3e4f5a6b",
    "operation_number": "RP-482913"
  }
}
```

**Cancelled:**

```json theme={null}
{
  "schema_version": "2026-09-01",
  "message_type": "transaction_result",
  "event_id": "b3e1f0a2-7c4d-4e8f-a1b2-c3d4e5f60718",
  "timestamp": "2026-09-28T14:03:12.640118Z",
  "message": {
    "id": "df9d41d8-ac80-5125-9738-ce1fcb8dc523",
    "quote_id": "cf6c57ea-03a1-41dc-99e8-b91cc1b7333e",
    "external_ref": "00010203-0405-0607-0809-0a0b0c0d0e0f",
    "op_type": "BUY",
    "amount": "1500",
    "amount_currency": "fiat",
    "currency": "ARS",
    "fiat_amount": null,
    "asset": null,
    "status": "CANCELLED",
    "txn_id": null,
    "operation_number": null,
    "cancel_reason": "insufficient_balance"
  }
}
```

| Key | Type | Description |
| - | - | - |
| `id` | *string* | The same value sent in `transaction_approval_request` for a buy — use it to correlate the two. |
| `quote_id`, `external_ref`, `op_type`, `amount`, `amount_currency`, `currency` | | Same meaning and values as in [`transaction_approval_request`](#transaction_approval_request), so the two messages compare field by field. `op_type` is `BUY` or `SELL`. |
| `fiat_amount` | *string \| null* | The amount of `currency` the trade actually moved on the user's balance: debited on a `BUY`, credited on a `SELL`, with the fee already applied. `null` when cancelled. |
| `asset` | *string \| null* | The crypto asset traded (e.g. `BTC`). `null` when cancelled. |
| `status` | *string* | `COMPLETED` or `CANCELLED`. |
| `txn_id` | *string \| null* | The resulting transaction's identifier. `null` when cancelled. |
| `operation_number` | *string \| null* | The operation number shown to the user on the widget's success screen (`RP-` followed by six digits). `null` when cancelled. |
| `cancel_reason` | *string* (cancelled only) | Why the operation was cancelled — see below. |

<Warning>
  To update your record of the user's fiat balance, use `fiat_amount`, not `amount`. `amount` is what the user typed: when `amount_currency` is `crypto`, it is a quantity of `asset`, and it never reflects the fee.
</Warning>

#### Cancel reasons

An operation is cancelled only when it is certain that it did not execute: either Ripio rejected it, or the core was never called because your approval couldn't be used.

| `cancel_reason` | Meaning |
| - | - |
| `transaction_approval_unavailable` | Your endpoint didn't answer the approval request in time, or answered with something that isn't a JSON object. You may have approved it — release whatever you reserved. |
| `transaction_approval_signature_invalid` | Your approval response had a missing or invalid signature, or an `event_id` that didn't match. See [Signing your response](#signing-your-response). |
| `quote_expired` | The quote expired before the operation executed. |
| `quote_not_found` | The quote doesn't exist for your account. |
| `quote_price_unavailable` | There was no price available to execute the quote. |
| `insufficient_balance` | The user didn't have enough balance. |
| `amount_below_minimum` | The amount is below the minimum per operation. |
| `amount_above_maximum` | The amount is above the maximum per operation. |
| `quote_amount_rejected` | The amount was rejected as invalid. |
| `periodic_amount_limit_reached` | The user reached the amount limit for the current period. |
| `periodic_frequency_limit_reached` | The user reached the number of operations allowed for the current period. |
| `end_user_not_operable` | The user is not allowed to operate. |
| `quote_execution_irrecoverable` | The operation could not be executed and can't be retried on the same quote. |

Treat any other value as a cancellation too: new reasons may be added.

#### When you don't receive a result

* **You denied the approval.** No result is sent — you already know the outcome, and you reserved nothing.
* **The outcome is uncertain.** If the execution fails in a way that doesn't say whether the trade happened (a timeout or an internal error), no result is sent for now. The user can retry the same operation from the widget, and if the trade did go through, you receive its `COMPLETED` then.

<Note>
  The same `id` can receive more than one `transaction_approval_request`: when the user retries after an uncertain failure, the retry asks for your approval again, with a new `event_id`. Answer each one on its own merits — only the `transaction_result` closes the operation.
</Note>

<Note>
  For a buy, persisting `id` is generally enough to reference the operation later; `operation_number` is what the user sees, so it's the easiest reference if they reach out to you about it.
</Note>

### Configuring your endpoint

Your webhook URL, HMAC signing key and Ed25519 response public key are set on your account during onboarding — reach out to your Ripio contact to configure or change them.

**Key considerations for your endpoint:**

* **HTTPS.** Your endpoint URL must use HTTPS and resolve to a public address. Redirects (`3xx`) are not followed — point the URL at the final endpoint.
* **Respond quickly.** For `transaction_approval_request`, respond within 5 seconds — the operation is fail-closed, so a slow response means the operation doesn't go through.
* **Sign your approval.** Every response to `transaction_approval_request` must carry `X-Partner-Signature-Ed25519` and echo the request's `event_id`. Only one public key is active per account at a time, so coordinate a key rotation with your Ripio contact.
* **Idempotency.** Design your handler around `event_id`: processing the same event twice must not double-count anything on your side.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.