Skip to main content
The widget notifies your backend of two things: a buy asks for your approval before it executes, and every operation — buy, sell, deposit or withdrawal — reports its result once it’s settled. Both are POST requests to the webhook URL configured on your account. Signatures go both ways, with the same scheme — Ed25519 over the raw body, sent as ed25519=<hex> — and a key pair on each side: Each side signs with a key only it holds, so a valid signature proves who produced the message — something a shared secret can’t do, since either side could have computed it. No secret is ever shared, in either direction.
This is a separate contract from the Crypto as a Service webhooks used by the core API — the widget’s events use their own envelope and their own signatures, documented below.

Envelope

Every message shares the same envelope:
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.

Verifying the signature

Every request carries an X-Ripio-Signature-Ed25519 header: an Ed25519 signature of the raw request body, made with Ripio’s private key.
You verify it with Ripio’s public key, which your Ripio contact hands you during onboarding (a PEM PUBLIC KEY). There’s one key per environment: the sandbox key doesn’t verify production requests, and vice versa. 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. Reject the request if the header is missing, doesn’t start with ed25519=, or doesn’t verify. Python (cryptography)
Node.js
Test against the reference vector This throwaway test key is not valid for any environment. The body below, verified with it, must accept exactly this header — and reject it after any change to a single byte of the body:

Signing your response

Your answer to both messages — the decision on a transaction_approval_request and the acknowledgement of a transaction_result — 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 answer came from your systems. Each response body includes the request’s event_id. That’s what binds each signature to a single delivery: without it, every {"approved": true} would be the same bytes and the same signature, reusable for any other purchase. 1. Generate the key pair
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. Your public key is required before Ripio can send webhooks to 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)
Node.js
3. Test against the reference vectors Use this throwaway test key pair — it is not valid for any account — to confirm your implementation signs the way Ripio verifies. Signing each body below with the private key must produce exactly that signature (Ed25519 is deterministic):

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.
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.

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):
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.
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.

transaction_result

Tells you how an operation 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, for:
  • Buys and sells executed from the widget — described in this section.
  • Withdrawals the user sends from the widget, and deposits credited to your users’ deposit addresses — same envelope and statuses, with an on-chain payload. See Deposits and withdrawals.
Each operation gets at most one result, identified by message.id, with one of two statuses:
  • COMPLETED — the trade executed, or the transfer was confirmed.
  • 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: anything other than a signed acknowledgement (see Acknowledging), 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, and acknowledge the duplicate too. Completed:
Cancelled:
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.

Acknowledging

To confirm you received a result, respond with any 2xx status and a JSON body that includes the event_id of the request, signed like your approval responses (see Signing your response):
A result counts as delivered only when the status is 2xx, the body carries "received": true and the request’s event_id, and X-Partner-Signature-Ed25519 verifies under your public key. A bare 2xx — unsigned, wrongly signed, or for another event_id — is not an acknowledgement: Ripio retries it like a missing response, and records the delivery as failed once the retries run out. That’s what lets either side prove later that a result was or wasn’t received.

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. 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.
  • A deposit or withdrawal isn’t final yet, or never got created. See When it’s sent.
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.
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.

Deposits and withdrawals

Deposits and withdrawals report their result through the same transaction_result: same envelope, same COMPLETED / CANCELLED statuses, same delivery and the same acknowledgement. What changes is the message — there’s no quote and no fiat side, but the on-chain details of the transfer, and the status Ripio’s core reports for it.
Deposit and withdrawal results are enabled per account. Ask your Ripio contact to turn them on.

When it’s sent

A result is sent once the transfer is final — never while it’s still in progress:
  • Deposit — when it’s credited: the network confirmed it and Ripio completed its checks. Sent for every deposit to any of your users’ deposit addresses, whether or not they had the widget open when the funds arrived. A deposit that was detected but isn’t confirmed yet doesn’t produce a result.
  • Withdrawal — when it’s confirmed on-chain (COMPLETED) or fails after being submitted (CANCELLED), only for withdrawals sent from the widget. The “Transfer started” screen the user sees is not the result: at that point the withdrawal is still pending, and it can take some time for the network to confirm it.
A withdrawal Ripio rejects before creating it — insufficient balance, an invalid address or memo, an expired network fee, an amount outside the limits — doesn’t produce a result either: the user is shown the error in the widget, and nothing was debited. Deposit completed:
Withdrawal cancelled after it was submitted:
A deposit blocked by compliance arrives as status: "COMPLETED" and core_status: "COMPLETED" with is_blocked: true: the funds reached Ripio, but they are held and aren’t available in the user’s balance. Don’t credit them on your side while is_blocked is true. If the funds are released later, no new result is sent.
amount is a quantity of asset, never fiat: a deposit or withdrawal doesn’t convert anything. Use it, together with asset, to update your record of the user’s crypto balance.

On-chain cancel reasons

A cancelled deposit or withdrawal carries a cancel_reason built from the core’s status: core_ followed by core_status in lowercase. Treat any other core_ value as a cancellation too.
If the user retries a withdrawal that was cancelled after it was submitted, the widget doesn’t send it again — it shows the same transfer with its final status. To send the funds again, the user starts a new transfer, which gets a new id.

Configuring your endpoint

Webhooks are set up during onboarding, as an exchange of public keys in both directions — reach out to your Ripio contact to configure or change anything: 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.
  • Deposits and withdrawals. Their results are enabled per account — ask your Ripio contact to turn them on (see Deposits and withdrawals).
  • 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.
  • Verify every request. Check X-Ripio-Signature-Ed25519 over the raw body and the timestamp before acting on a message.
  • Sign every response. Your answer to both transaction_approval_request and transaction_result must carry X-Partner-Signature-Ed25519 and echo the request’s event_id.
  • Key rotation. Only one key is active per side at a time, so a rotation — of your key or Ripio’s — is coordinated with your Ripio contact.
  • Idempotency. Design your handler around event_id: processing the same event twice must not double-count anything on your side.