TuCOPRamp

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

TypeCategoryWhen
pingplatformA test of one of your endpoints, sent from the console (Enviar prueba) or by TuCOP. Always sent, whatever the endpoint's event types.
party.updatedon_off_rampThe verification status, endorsements or restriction of a party linked to your app changed. Sent to every app linked to the party. See Parties.
destination.verifiedon_off_rampA destination your consumer created passed the Bre-B directory check. See Destinations.
destination.rejectedon_off_rampA destination your consumer created failed the check, or the directory did not answer in time.
operation.updatedon_off_rampA 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.drainedon_off_rampA 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.disabledon_off_rampA withdrawal address was disabled. Sent once, to the consumer that created the address. See Withdrawals.
deposit.receivedon_off_rampActivity 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

json
{
  "consumer_id": "con_0bdy554f0x9szsmatwdnz8z6gb",
  "endpoint_id": "whep_1x5gdgn7we9ny8ptjz9ryjr6ra"
}

party.updated

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "withdrawal_id": "wdr_01j9z3k8q2m4n6p8r0s2t4v6w8",
  "destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
  "status": "disabled",
  "disabled_at": "2026-10-14T15:04:10.003Z"
}

deposit.received

json
{
  "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

http
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"}}
HeaderMeaning
webhook-idEvent id. The same value as id in the body, and the same on every retry.
webhook-timestampUnix seconds when this attempt was signed. Every retry is signed again.
webhook-signatureOne or more space-separated v1,<base64> signatures.
FieldMeaning
idEvent id (evt_...). See ids.
typeEvent type.
categoryService category of the type: on_off_ramp or platform (p2p is reserved). See Event types.
timestampWhen the event happened, ISO 8601 in UTC. Not when this attempt was sent.
api_versionVersion of the payload format. Each endpoint is pinned to one.
dataIds, 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

  1. Read the raw body bytes before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
  2. Reject the request if webhook-timestamp is more than 5 minutes from your clock.
  3. Take the part of your secret after whsec_ and base64-decode it. That is the HMAC key.
  4. Compute HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body> and base64-encode it.
  5. Split webhook-signature on spaces. Accept the request if any v1,<value> entry equals your value, compared in constant time. Ignore entries with another version.
  6. Only then parse the body.
Node (standardwebhooks)
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);
Node (no library)
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);
Python (standardwebhooks)
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
Python (no library)
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 2xx response within 15 seconds counts as delivered. Redirects are not followed; a 3xx is 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 attemptNext attempt in
    15 seconds
    25 minutes
    330 minutes
    42 hours
    55 hours
    610 hours
    714 hours
    820 hours
    924 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 /events or 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 Gone is 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:

bash
curl "https://sandbox.api.ramp.tucop.xyz/events?after=1842&limit=100" \
  --header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY"
json
{
  "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
}
ParameterMeaning
afterReturn events with a seq above this. Default 0, the start of the feed.
limitEvents per page, 1 to 200. Default 50.
categoryOnly one service's events: on_off_ramp or platform. Default: all.
  • has_more is true when more events are waiting: call again at once with next_after. When it is false, wait before the next poll.
  • On an empty page, next_after is the after you 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.updated for 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.