Webhooks
Covalent sends signed events about transfers and cases to your webhook subscriptions. Manage subscriptions through /api/v2/webhooks, or in the dashboard under Developers, Webhooks. A subscription belongs to the API key's environment, and receives that environment's events only.
Endpoints
Section titled “Endpoints”| Method | Endpoint | Scope | Description |
|---|---|---|---|
| GET | /api/v2/webhooks |
webhooks:read |
List subscriptions |
| POST | /api/v2/webhooks |
webhooks:write |
Create a subscription |
| GET | /api/v2/webhooks/:id |
webhooks:read |
Get a subscription, its stats and latest deliveries |
| PATCH | /api/v2/webhooks/:id |
webhooks:write |
Change a subscription or rotate its secret |
| POST | /api/v2/webhooks/:id/test |
webhooks:write |
Send a test event |
| GET | /api/v2/webhooks/:id/deliveries |
webhooks:read |
List a subscription's deliveries |
| POST | /api/v2/webhooks/:id/deliveries/:deliveryId/retry |
webhooks:write |
Send a failed delivery again |
The key's owner's role needs webhooks:view to read, and webhooks:create, webhooks:update or webhooks:test for each change. A subscription of the other environment is 404 WEBHOOK_NOT_FOUND.
Create a Subscription
Section titled “Create a Subscription”curl -X POST "$COVALENT_URL/api/v2/webhooks" \ -H "x-api-key: $COVALENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Transfers", "url": "https://hooks.example.com/covalent", "events": ["transfer.created", "transfer.completed"], "description": "Ledger sync" }'| Field | Required | Description |
|---|---|---|
name |
Yes | At most 100 characters. |
url |
Yes | A public https URL, at most 2,048 characters. Addresses that resolve to private, reserved or local networks are refused (422 INVALID_URL). |
events |
Yes | One or more event types, or ["*"] for all. |
description |
No | At most 500 characters. |
The answer is 201, with the subscription and its signing secret:
{ "data": { "webhook": { "id": "webhook_id", "name": "Transfers", "url": "https://hooks.example.com/covalent", "urlMasked": false, "events": ["transfer.created", "transfer.completed"], "status": "active", "description": "Ledger sync", "environment": "live", "consecutiveFailures": 0, "lastDeliveryAt": null, "lastSuccessAt": null, "createdAt": "2026-10-09T12:00:00.000Z", "updatedAt": "2026-10-09T12:00:00.000Z", "deliveryCount": 0 }, "secret": "<64 hexadecimal characters>" }, "meta": { "apiVersion": 2, "timestamp": "2026-10-09T12:00:00.000Z" }}The secret is shown in this answer only, and never again: store it to verify signatures. A key that may not change webhooks (webhooks:write and the role's webhooks:update) sees each URL as its origin followed by /***, with urlMasked: true, since URLs often carry a token.
Read and List
Section titled “Read and List”GET /api/v2/webhooks lists the environment's subscriptions, newest first:
| Parameter | Description |
|---|---|
status |
active, paused or disabled. |
search |
Part of the ID, name, URL, status or description, or a whole event type. At most 100 characters. |
limit |
Items per page, 1 to 200, default 50. |
cursor |
meta.cursor of the previous page. |
GET /api/v2/webhooks/:id answers the subscription itself (not wrapped), with stats (total, sent, failed, pending, successRate, avgResponseTimeMs) and recentDeliveries, its 20 latest deliveries. Neither ever carries the secret.
Change a Subscription
Section titled “Change a Subscription”curl -X PATCH "$COVALENT_URL/api/v2/webhooks/webhook_id" \ -H "x-api-key: $COVALENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "paused", "regenerateSecret": true }'Send any of name, url, events, status (active, paused or disabled), description and regenerateSecret. A new URL is checked as on create. The answer is { "webhook": { ... } }, with secret beside it when regenerateSecret was true: the new secret is shown in that answer only.
Test and Retry
Section titled “Test and Retry”POST /api/v2/webhooks/:id/test sends a signed test.ping event to the subscription's URL now, and answers { "success", "deliveryId", "error" } (error only on failure).
GET /api/v2/webhooks/:id/deliveries lists the subscription's deliveries, newest first, without their payloads or signatures:
| Parameter | Description |
|---|---|
status |
pending, sent or failed. |
eventType |
One event type, such as transfer.created. |
limit, cursor |
As on every list. |
Each delivery has id, eventType, status, attempts, maxAttempts, httpStatus, responseTimeMs, lastError, nextRetryAt, sentAt and createdAt.
POST /api/v2/webhooks/:id/deliveries/:deliveryId/retry sends a failed delivery again, signed anew, and answers { "deliveryId", "delivered" }. A delivery that has not failed is 409 DELIVERY_NOT_FAILED; a subscription that is not active, 409 WEBHOOK_NOT_ACTIVE; a delivery of another subscription, 404 DELIVERY_NOT_FOUND.
Events
Section titled “Events”| Event | Meaning |
|---|---|
transfer.created |
Transfer record created |
transfer.updated |
Transfer changed |
transfer.completed |
Transfer completed |
transfer.accepted |
Transfer accepted |
transfer.rejected |
Transfer rejected |
transfer.cancelled |
Transfer cancelled |
transfer.held |
Transfer held |
transfer.failed |
Transfer failed |
case.created |
Case created |
case.updated |
Case changed |
case.resolved |
Case resolved |
compliance.screening_complete |
Compliance screening completed |
test.ping |
A test, sent to one subscription whatever its events. It cannot be subscribed to. |
Delivery Headers
Section titled “Delivery Headers”Content-Type: application/jsonX-Webhook-Signature: t=<unix>,alg=hmac-sha256,sig=<hex_hmac>X-Webhook-Event: transfer.createdX-Webhook-Delivery-Id: delivery_idPayload
Section titled “Payload”Transfer events use this shape:
{ "event": "transfer.created", "timestamp": "2026-10-09T12:00:00.000Z", "data": { "id": "transfer_id", "externalId": "client_id", "direction": "outgoing", "state": "queued", "amount": "1.25", "asset": "ETH", "network": "ethereum", "originatingVaspId": "local_vasp", "originatingVaspName": "Local VASP", "beneficiaryVaspId": "pending_discovery", "beneficiaryVaspName": null, "complianceStatus": "pending", "originatorStatus": "pending", "beneficiaryStatus": null, "createdAt": "2026-10-09T12:00:00.000Z", "updatedAt": "2026-10-09T12:00:00.000Z" }}Case events carry the case as it was at the change, with codes, IDs and dates only. Its free text (title, description, notes) can hold customer details and is left out: read it with an API key, whose owner's role decides whether it is masked.
{ "event": "case.resolved", "timestamp": "2026-10-09T12:00:00.000Z", "data": { "id": "case_id", "caseNumber": "CASE-00042", "type": "manual_escalation", "category": "aml", "priority": "high", "status": "closed", "resolution": "rejected", "assigneeId": "user_id", "ruleId": null, "transferIds": ["transfer_id"], "source": "manual", "createdAt": "2026-10-09T10:00:00.000Z", "updatedAt": "2026-10-09T12:00:00.000Z", "resolvedAt": "2026-10-09T12:00:00.000Z" }}Signature Verification
Section titled “Signature Verification”The signature covers the exact raw JSON body.
signed_payload = "<timestamp>.<raw_json_body>"signature = HMAC_SHA256(subscription_secret, signed_payload)header = "t=<timestamp>,alg=hmac-sha256,sig=<signature>"Verification rules:
- Parse
tas a Unix timestamp. - Require
algto behmac-sha256. - Parse
sigas the HMAC hex digest. - Reject signatures older than 5 minutes.
- Reject timestamps more than 1 minute in the future.
- Compare with a timing-safe equality check.
An automatic retry (after 1 second, then after 5 seconds) resends the signature made when the event was dispatched, with its original timestamp. A manual retry (POST /api/v2/webhooks/:id/deliveries/:deliveryId/retry) is signed again, with a new timestamp.
Node Verification Example
Section titled “Node Verification Example”import crypto from 'node:crypto';
export function verifyWebhook(rawBody, header, secret) { const parts = Object.fromEntries( header.split(',').map((part) => { const [key, value] = part.split('='); return [key.trim(), value.trim()]; }) );
const timestamp = Number(parts.t); if (parts.alg !== 'hmac-sha256' || !parts.sig) return false;
const age = Math.floor(Date.now() / 1000) - timestamp; if (age > 300 || age < -60) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex');
if (parts.sig.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(parts.sig), Buffer.from(expected));}Retry Behavior
Section titled “Retry Behavior”| Setting | Value |
|---|---|
| Timeout | 10 seconds |
| Max attempts | 3 |
| Retry delays | 1 second after the first attempt, 5 seconds after the second |
Destination URLs are checked when a subscription is saved and again before each delivery, to keep deliveries off private networks.