Skip to content

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.

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.

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

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.

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

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.

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.
Content-Type: application/json
X-Webhook-Signature: t=<unix>,alg=hmac-sha256,sig=<hex_hmac>
X-Webhook-Event: transfer.created
X-Webhook-Delivery-Id: delivery_id

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"
}
}

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 t as a Unix timestamp.
  • Require alg to be hmac-sha256.
  • Parse sig as 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.

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));
}
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.