Webhooks
Signed events TuCOP sends to your endpoints, how to verify them, and what happens when they fail.
TuCOP sends events to HTTPS endpoints you register. A consumer can have several endpoints (for
example one per backend), each with its own URL, its own signing secret and an optional list of
event types it wants. Each endpoint has a signing secret that starts with whsec_. It is shown
once, when the endpoint is created or its secret is rotated.
Requests follow the Standard Webhooks specification, so you can
verify them with the official libraries (npm install standardwebhooks,
pip install standardwebhooks, and others) or with a few lines of your own code.
Manage endpoints in the console
You create and manage your endpoints yourself in the integrator console, on the Avisos page (avisos is Spanish for notices). There you can:
- create an endpoint: an
https://URL, an optional description and, optionally, the event types it should receive (none selected means every type). The signing secret is shown once, right after you save; copy it then, it cannot be shown again; - edit the URL, the description or the event types;
- pause an endpoint and resume it. A paused endpoint receives nothing and its pending deliveries are dropped;
- rotate the secret (see Secret rotation). The new secret is also shown once;
- send a test
pingand see your last deliveries, with the date, the event type, the HTTP status your server answered and the result; - delete an endpoint.
Sandbox endpoints are separate: manage them from the console's Sandbox page, under its own Avisos. Each environment signs with its own secrets.
You can have up to 10 endpoints per environment from the console. TuCOP can also pause an endpoint,
for example one that is misbehaving. The console then shows it as paused by TuCOP, and while the
pause lasts you cannot resume, edit or delete it (409 webhook_endpoint_paused_by_tucop), nor add
an endpoint, or move one, to the same host (409 webhook_host_paused_by_tucop). It still counts
toward the 10. Write to support@tucop.org; TuCOP resumes it or deletes it.
An 11th endpoint is refused with 409 webhook_endpoint_limit: delete one first.
Event types
Every event belongs to one service category, sent as category next to type: on_off_ramp
for payments, withdrawals, deposits, parties and destinations; platform for pings. A third
value, p2p, is reserved for TuCOP's own apps and never reaches an integrator endpoint today.
| Type | Category | When |
|---|---|---|
ping | platform | A test of one of your endpoints, sent from the console (Enviar prueba) or by TuCOP. Always sent, whatever the endpoint's event types. |
party.updated | on_off_ramp | The verification status, endorsements or restriction of a party linked to your app changed. Sent to every app linked to the party. See Parties. |
destination.verified | on_off_ramp | A destination your consumer created passed the Bre-B directory check. See Destinations. |
destination.rejected | on_off_ramp | A destination your consumer created failed the check, or the directory did not answer in time. |
operation.updated | on_off_ramp | A payment changed status. Sent once per status. While the payment is awaiting_funds, it is also sent again, with the same status, when deposit_amount changes. See Payments. |
withdraw.drained | on_off_ramp | A payout from a withdrawal address was created or changed status. Sent once per payout and status: processing, then completed, failed, refunded or canceled (the same statuses as a payment). See Withdrawals. |
withdrawal.disabled | on_off_ramp | A withdrawal address was disabled. Sent once, to the consumer that created the address. See Withdrawals. |
deposit.received | on_off_ramp | Activity on a deposit account: pesos arrived, or the USDC was sent. Sent once per activity line, to the consumer that created the account, as soon as the provider reports it. See Deposits. |
More types will be added. An endpoint's event types must come from this list; anything else is
rejected when you save the endpoint. Answer 2xx to any type you do not handle, so it is not
retried. The API reference tab describes each event under
Webhooks.
Event data
data has a fixed shape for each type. These are the data objects TuCOP sends; the rest of the
body is described under Request.
ping
{
"consumer_id": "con_0bdy554f0x9szsmatwdnz8z6gb",
"endpoint_id": "whep_1x5gdgn7we9ny8ptjz9ryjr6ra"
}
party.updated
{
"party_id": "pty_01j9z3k8q2m4n6p8r0s2t4v6w8",
"status": {
"kyc": "approved",
"tos": "accepted",
"endorsements": [{ "name": "cop", "status": "approved" }]
},
"restricted": false,
"updated_at": "2026-10-08T15:20:40.512Z"
}
status has the same values as on GET /parties/me.
destination.verified
{
"destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"ownership": "third_party",
"status": "verified",
"rejection_reason": null,
"key_masked": "****4567",
"verified_at": "2026-10-08T15:02:51.120Z",
"confirmed_at": null,
"updated_at": "2026-10-08T15:02:51.120Z"
}
The holder (name, bank, document) is never in the event, on purpose: it is personal data. Read it
with GET /destinations/{id} and show it to the payer. A missing holder in the event does not
mean the holder is empty: never write it over a holder you already stored. confirmed_at is set
once the payer confirmed the holder; payments need it.
destination.rejected
{
"destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"ownership": "third_party",
"status": "rejected",
"rejection_reason": "directory_no_match",
"key_masked": "****4567",
"verified_at": null,
"confirmed_at": null,
"updated_at": "2026-10-08T15:02:51.120Z"
}
rejection_reason is one of the values listed in
Wait for the directory.
operation.updated
{
"operation_id": "op_01j9z3kb5c6d7e8f9g0h1j2k3m",
"product": "pay",
"status": "processing",
"destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"destination_amount": { "amount": "200000.00", "asset": "COP" },
"source_amount": { "amount": "51.27", "asset": "USDC" },
"deposit_amount": { "amount": "51.27", "asset": "USDC" },
"fees": {
"tucop": { "amount": "0.770000", "asset": "USDC" },
"consumer": { "amount": "0.256350", "asset": "USDC" }
},
"error_code": null,
"updated_at": "2026-10-08T15:04:10.003Z"
}
deposit_amount is the payment's deposit.amount. An event for a payment that is still
awaiting_funds can carry a new deposit_amount with the same status. Right before the payer
sends, read deposit.amount from GET /payments/{id}.
withdraw.drained
{
"withdrawal_id": "wdr_01j9z3k8q2m4n6p8r0s2t4v6w8",
"destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"payout_id": "3f9c2a1e-5b7d-4c8e-9a0b-1d2e3f4a5b6c",
"status": "completed",
"amount": { "amount": "25.123456", "asset": "USDC" },
"destination_amount": { "amount": "98500.00", "asset": "COP" },
"error_code": null,
"created_at": "2026-10-11T15:04:10.003Z",
"updated_at": "2026-10-11T15:05:10.003Z",
"completed_at": "2026-10-11T15:05:10.003Z"
}
The fields are those of a payout; payout_id is the payout's
id.
withdrawal.disabled
{
"withdrawal_id": "wdr_01j9z3k8q2m4n6p8r0s2t4v6w8",
"destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"status": "disabled",
"disabled_at": "2026-10-14T15:04:10.003Z"
}
deposit.received
{
"deposit_id": "dep_01j9z3k8q2m4n6p8r0s2t4v6w8",
"event_id": "7c1e9a52-3f0b-4d6e-8a2c-5b9d0e1f2a3b",
"type": "funds_received",
"amount": { "amount": "100000.00", "asset": "COP" },
"created_at": "2026-10-11T15:04:10.003Z",
"destination_address": "0x1111111111111111111111111111111111111111"
}
One event per line of the account's activity. event_id is the line's id in
GET /deposits/{id}, and type the provider's name for the
activity. amount is in COP when the provider reports pesos, otherwise in USDC. It is
null when the provider gave no amount. destination_address is the wallet the USDC goes to:
the one the activity names, else the account's wallet when TuCOP read the activity (see
Change the wallet). Events sent before it was added do not have it.
Request
POST /your/webhook/path HTTP/1.1
content-type: application/json
user-agent: TuCOPRamp-Webhooks/2.0
webhook-id: evt_3f3gn1wv2y9e59mn9z1xn1pb1x
webhook-timestamp: 1791407052
webhook-signature: v1,Z4W6G7gxqWMDzv4tLJjy4ACXAU09jpkyz94L7QNlS5U=
{"id":"evt_3f3gn1wv2y9e59mn9z1xn1pb1x","type":"ping","category":"platform","timestamp":"2026-10-07T21:04:11.512Z","api_version":"2026-10-08","data":{"consumer_id":"con_0bdy554f0x9szsmatwdnz8z6gb","endpoint_id":"whep_1x5gdgn7we9ny8ptjz9ryjr6ra"}}
| Header | Meaning |
|---|---|
webhook-id | Event id. The same value as id in the body, and the same on every retry. |
webhook-timestamp | Unix seconds when this attempt was signed. Every retry is signed again. |
webhook-signature | One or more space-separated v1,<base64> signatures. |
| Field | Meaning |
|---|---|
id | Event id (evt_...). See ids. |
type | Event type. |
category | Service category of the type: on_off_ramp or platform (p2p is reserved). See Event types. |
timestamp | When the event happened, ISO 8601 in UTC. Not when this attempt was sent. |
api_version | Version of the payload format. Each endpoint is pinned to one. |
data | Ids, statuses and amounts. Never personal data. Read the resource through the API if you need more. |
The signature in this example was made with the secret
whsec_dHVjb3ByYW1wLWV4YW1wbGUtc2VjcmV0LW5vdC11c2U=. Use it to test your verification code (with
the timestamp check turned off, since the example is old).
Verify the signature
- Read the raw body bytes before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
- Reject the request if
webhook-timestampis more than 5 minutes from your clock. - Take the part of your secret after
whsec_and base64-decode it. That is the HMAC key. - Compute HMAC-SHA256 of
<webhook-id>.<webhook-timestamp>.<raw body>and base64-encode it. - Split
webhook-signatureon spaces. Accept the request if anyv1,<value>entry equals your value, compared in constant time. Ignore entries with another version. - Only then parse the body.
import express from 'express';
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.TUCOPRAMP_WEBHOOK_SECRET); // whsec_...
const app = express();
app.post('/webhooks/tucopramp', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
// Checks the timestamp (5 minutes) and every signature in the header.
event = wh.verify(req.body, {
'webhook-id': req.get('webhook-id'),
'webhook-timestamp': req.get('webhook-timestamp'),
'webhook-signature': req.get('webhook-signature')
});
} catch {
return res.status(401).end();
}
// If event.id was already processed, answer 200 and stop.
// Otherwise store event.id, queue the work, and answer quickly.
res.status(200).end();
});
app.listen(3000);
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRET = process.env.TUCOPRAMP_WEBHOOK_SECRET; // whsec_...
const KEY = Buffer.from(SECRET.slice('whsec_'.length), 'base64');
const TOLERANCE_SECONDS = 5 * 60;
function isValid(rawBody, id, timestamp, signatures) {
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', KEY)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const got = Buffer.from(value, 'base64');
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
const app = express();
app.post('/webhooks/tucopramp', express.raw({ type: 'application/json' }), (req, res) => {
const ok = isValid(
req.body,
req.get('webhook-id'),
req.get('webhook-timestamp'),
req.get('webhook-signature')
);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
// If event.id was already processed, answer 200 and stop.
// Otherwise store event.id, queue the work, and answer quickly.
res.status(200).end();
});
app.listen(3000);
import os
from flask import Flask, abort, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["TUCOPRAMP_WEBHOOK_SECRET"]) # whsec_...
app = Flask(__name__)
@app.post("/webhooks/tucopramp")
def tucopramp_webhook():
try:
# Checks the timestamp (5 minutes) and every signature in the header.
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
abort(401)
# If event["id"] was already processed, answer 200 and stop.
# Otherwise store event["id"], queue the work, and answer quickly.
return "", 200
import base64
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["TUCOPRAMP_WEBHOOK_SECRET"] # whsec_...
KEY = base64.b64decode(SECRET.removeprefix("whsec_"))
TOLERANCE_SECONDS = 5 * 60
app = Flask(__name__)
def is_valid(raw_body: bytes, msg_id: str | None, timestamp: str | None, signatures: str | None) -> bool:
if not msg_id or not timestamp or not signatures:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, value = entry.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return False
@app.post("/webhooks/tucopramp")
def tucopramp_webhook():
raw_body = request.get_data()
if not is_valid(
raw_body,
request.headers.get("webhook-id"),
request.headers.get("webhook-timestamp"),
request.headers.get("webhook-signature"),
):
abort(401)
event = json.loads(raw_body)
# If event["id"] was already processed, answer 200 and stop.
# Otherwise store event["id"], queue the work, and answer quickly.
return "", 200
Secret rotation
When you rotate a secret in the console (Cambiar secreto), TuCOP shows the new one once and
keeps signing with both for an overlap window of 24 hours: webhook-signature then carries two entries, one per
secret. Deploy the new secret within the window. Code that accepts any matching entry, as above,
keeps working throughout.
Secrets issued before October 2026 were encoded with the URL-safe base64 alphabet, which strict
libraries do not decode. If your secret contains - or _, rotate it in the console.
Delivery and retries
-
Any
2xxresponse within 15 seconds counts as delivered. Redirects are not followed; a3xxis a failed attempt. -
The first attempt goes out as soon as the event is recorded. After a failed attempt the event is sent again after these delays, each varied by up to 10 percent either way so that many retries do not land at once:
After attempt Next attempt in 1 5 seconds 2 5 minutes 3 30 minutes 4 2 hours 5 5 hours 6 10 hours 7 14 hours 8 20 hours 9 24 hours That is ten attempts over about 75 hours. After the tenth failure the event is not sent again on its own; read it from
GET /eventsor ask TuCOP for a replay. -
Every attempt is signed again with a new timestamp.
-
TuCOP sends to one endpoint one request at a time. Different endpoints are served in parallel.
-
After 5 failed attempts in a row on an endpoint, TuCOP pauses it for 1 minute, then sends a single request to test it. Each further failure doubles the pause, up to 1 hour. The first success ends the pause. Events keep queuing while the endpoint is paused.
-
An endpoint that answers
410 Goneis disabled at once. An endpoint that has failed every attempt for 3 days is disabled too. A disabled endpoint receives nothing and its pending deliveries are dropped. The console shows it as paused with the reason; fix your server and resume it from Avisos. -
If your endpoint is deleted or your consumer is paused, pending deliveries are dropped.
-
Your endpoint must be reachable over HTTPS at a public address. URLs that resolve to private, loopback or link-local addresses are refused when set up and on every delivery.
Ordering and duplicates
Events are not guaranteed to arrive in order. A retry of an older event can arrive after a newer
one, and two endpoints receive the same event independently. Use the event's timestamp and read
the resource through the API when order matters.
Events can also arrive more than once, for example when your endpoint answers after the 15 second
timeout. Store each webhook-id you process. When an id comes back, answer 200 without doing the
work again.
Catch up with the event feed
GET /events (under Events in the API reference tab) returns your events, oldest first,
with the same id, type, category, timestamp, api_version and data as the webhook body,
plus a seq number. Store next_after from each response and pass it as after on the next call:
curl "https://sandbox.api.ramp.tucop.xyz/events?after=1842&limit=100" \
--header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY"
{
"data": [
{
"id": "evt_3f3gn1wv2y9e59mn9z1xn1pb1x",
"seq": 1843,
"type": "operation.updated",
"category": "on_off_ramp",
"timestamp": "2026-10-08T15:04:10.003Z",
"api_version": "2026-10-08",
"data": {
"operation_id": "op_01j9z3kb5c6d7e8f9g0h1j2k3m",
"product": "pay",
"status": "processing",
"...": "..."
}
}
],
"next_after": 1843,
"has_more": false
}
| Parameter | Meaning |
|---|---|
after | Return events with a seq above this. Default 0, the start of the feed. |
limit | Events per page, 1 to 200. Default 50. |
category | Only one service's events: on_off_ramp or platform. Default: all. |
has_moreistruewhen more events are waiting: call again at once withnext_after. When it isfalse, wait before the next poll.- On an empty page,
next_afteris theafteryou sent. - Any other query parameter, or a value out of range, gets
400 invalid_query. - The feed has the events sent to your consumer, and
party.updatedfor parties linked to your app at the time.
An event appears in the feed once it is committed, and gets a seq higher than anything already
returned, so polling with next_after never skips an event, even one that committed late. You can
use the feed to recover after downtime, or poll it instead of receiving webhooks.
Test delivery and replay
In the console, Enviar prueba sends a ping to one of your endpoints at once, even while the
endpoint is in a failure pause (not when you paused it), and shows what your server answered.
Ver entregas lists the endpoint's last 50 deliveries: date, event type, HTTP status and result.
Use it to check your URL, your TLS setup and your signature code before real events depend on
them.
TuCOP can also replay any delivery: it is sent again from the first attempt, with a full retry
schedule, under the same webhook-id.