Skip to main content
Webhooks deliver real-time notifications to your application when events occur in the Custody API. Every webhook delivery is signed with HMAC-SHA256 so you can verify authenticity.

Registering a webhook endpoint

curl -X POST "$BASE_URL/webhooks" \
  -H "Authorization: Bearer $CUSTODY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/custody",
    "events": ["transaction.*", "deposit.*"]
  }'
{
  "id": "wh_abc123",
  "url": "https://your-app.example.com/webhooks/custody",
  "events": ["transaction.*", "deposit.*"],
  "secret": "whsec_...",
  "status": "active",
  "created_at": "2026-03-19T10:00:00Z"
}
Save the secret — it is shown only once. You need it to verify webhook signatures.

Event types

EventTrigger
transaction.createdNew transaction submitted
transaction.status_changedTransaction state transition
transaction.completedTransaction confirmed on-chain
transaction.failedTransaction reverted or broadcast failure
transaction.approval_requiredPolicy requires manual approval
deposit.detectedInbound transfer detected on a registered wallet
deposit.confirmedInbound transfer confirmed with sufficient depth
approval.decisionAn approval or rejection decision was made
Use * wildcards in event subscriptions — transaction.* subscribes to all transaction events.
Pass an empty events array (or omit it) to subscribe to all event types.

Webhook payload format

Every delivery includes these headers:
HeaderDescription
X-Webhook-IDUnique delivery ID
X-Webhook-TimestampUnix timestamp of the delivery attempt
X-Webhook-SignatureHMAC-SHA256 signature for verification
Content-Typeapplication/json
The JSON body follows this structure:
{
  "id": "evt_xyz789",
  "type": "transaction.status_changed",
  "timestamp": "2026-03-19T10:05:00Z",
  "data": {
    "transaction_id": "tx_jkl012",
    "status": "COMPLETED",
    "previous_status": "CONFIRMING"
  }
}

HMAC-SHA256 verification

Always verify the X-Webhook-Signature header to confirm the payload came from QC Custody and hasn’t been tampered with. The signature is computed as:
HMAC-SHA256(webhook_secret, timestamp + "." + raw_body)
import hmac
import hashlib

def verify_webhook(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
    message = f"{timestamp}.".encode() + body
    expected = hmac.new(
        secret.encode(),
        message,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# In your webhook handler:
# timestamp = request.headers["X-Webhook-Timestamp"]
# signature = request.headers["X-Webhook-Signature"]
# body = request.body (raw bytes)
# verify_webhook(webhook_secret, timestamp, body, signature)
Always use constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js, hmac.Equal in Go) to prevent timing attacks.

Retry behavior

Failed deliveries are retried with exponential backoff:
AttemptDelay
1Immediate
230 seconds
32 minutes
410 minutes
51 hour
66 hours
After all retries are exhausted, the event is moved to the dead-letter queue for manual inspection.

What counts as a failure?

  • HTTP response status outside 200–299
  • Connection timeout (10 seconds)
  • DNS resolution failure
  • TLS handshake failure

Managing endpoints

# List all webhook endpoints
curl "$BASE_URL/webhooks" \
  -H "Authorization: Bearer $CUSTODY_API_KEY"

# Delete a webhook endpoint
curl -X DELETE "$BASE_URL/webhooks/{id}" \
  -H "Authorization: Bearer $CUSTODY_API_KEY"

Best practices

Respond quickly

Return 200 OK immediately and process the event asynchronously. Slow responses trigger retries.

Handle duplicates

Use the id field to deduplicate — retries may deliver the same event multiple times.

Verify signatures

Always validate HMAC-SHA256 before processing. Reject unsigned or invalid deliveries.

Monitor dead letters

Set up alerting on dead-letter events to catch configuration issues early.