Webhooks
A webhook is how cTpeWay tells your server that something happened: an invoice was paid, a deposit is held, a payout was confirmed. cTpeWay sends an HTTP POST with a JSON body to a URL you choose.
Use webhooks instead of asking the API again and again. They are faster, and they are the only reliable way to hear about a payment that arrives after your customer has closed the checkout page.
Set up an endpoint
Section titled “Set up an endpoint”-
Write a route on your server that accepts a
POSTwith a JSON body. -
Register its URL, in the dashboard under Developers → Webhooks or with the API, and choose which events it receives.
Terminal window curl -X POST "https://api.ctpeway.com/v1/webhook_endpoints" \-H "Authorization: Bearer $CTPEWAY_API_KEY" \-H "Content-Type: application/json" \-d '{"url": "https://shop.example/webhooks/ctpeway","events": ["invoice.paid", "settlement.held", "settlement.released"]}'const endpoint = await ctpeway.webhookEndpoints.create({url: 'https://shop.example/webhooks/ctpeway',events: ['invoice.paid', 'settlement.held', 'settlement.released'],});saveSecret(endpoint.secret); // shown once: store it, do not log itUse
"events": ["*"]to receive every event. -
Store the signing secret. The response contains
secret, which starts withwhsec_. It is returned only this once. You need it to verify deliveries. -
Verify every delivery before you act on it. See Verify the signature.
In live mode the URL must use https and be on a domain you have verified. In test mode localhost and hostnames ending in .test are accepted as well.
Test mode and live mode have separate endpoints and separate secrets. Events from test mode go only to test endpoints.
What a delivery looks like
Section titled “What a delivery looks like”Every event has the same envelope. data.object holds the thing the event is about.
{ "id": "evt_...", "type": "invoice.paid", "api_version": "v1", "created": 1791000000, "livemode": true, "data": { "object": {} }}| Field | Meaning |
|---|---|
id |
Unique id of the event, starting with evt_. Use it to recognise a delivery you have already handled. |
type |
The event type. It decides what data.object contains. |
api_version |
The version of the event format. |
created |
When the event happened, as Unix time in seconds. |
livemode |
false for an event from test mode. |
data.object |
The invoice, payment, settlement or other object. Each event’s fields are listed under Webhook events. |
The request also carries the header Ctpeway-Signature.
Events
Section titled “Events”An event name that links to a page is published: the page lists every field of its payload. A name marked planned comes from the v2 design and has no published payload yet.
Invoices
Section titled “Invoices”| Event | Sent when |
|---|---|
| invoice.created | An invoice was created. |
| invoice.option_selected | The payer chose how to pay. |
| invoice.payment_detected | A payment to the invoice was seen on the network. Not final yet. |
| invoice.underpaid | Confirmed payments are below the amount due. |
| invoice.paid | The invoice is paid. Fulfil the order on this event. |
| invoice.expired | The invoice expired unpaid. |
| invoice.canceled | You canceled the invoice. |
| invoice.late_payment | A payment was confirmed after the invoice expired or was canceled. |
| invoice.payment_reverted | A payment to the invoice was removed from the network. |
| invoice.partially_refunded | Part of what was paid to the invoice went back to the payer. |
| invoice.refunded | Everything paid to the invoice went back to the payer. |
Reusable deposit addresses
Section titled “Reusable deposit addresses”| Event | Sent when |
|---|---|
| deposit.payment_detected | A payment to a deposit address was seen. Not final yet. |
| deposit.payment_confirmed | A payment to a deposit address was confirmed. Credit your customer on this event. |
| deposit.payment_released | The payment was moved to your master wallet. |
Payments
Section titled “Payments”| Event | Sent when |
|---|---|
| payment.detected | A transfer to one of your addresses was seen. Not final yet. |
| payment.confirmed | The payment reached the network’s confirmation threshold. |
| payment.orphaned | A network reorganisation removed the payment’s transaction. |
| payment.dropped | An unconfirmed payment disappeared without being included. |
| payment.transaction_attached | The transaction of a payment that was found by a balance change has been identified. Until then the payment has no tx_hash. See Payments. |
Settlement and master wallet
Section titled “Settlement and master wallet”| Event | Sent when |
|---|---|
| settlement.held | A confirmed deposit is waiting in its address, usually because the master wallet is short of native coin. |
| settlement.released | The deposit was moved to your master wallet. |
| settlement.failed | The release could not be completed. |
| master_wallet.low_balance | Deposits are held because the master wallet is short of native coin. |
Payouts and refunds
Section titled “Payouts and refunds”| Event | Sent when |
|---|---|
| payout.created | A payout was created. |
| payout.broadcast | The payout’s transaction was sent to the network. |
| payout.confirmed | The payout was confirmed. |
| payout.failed | The payout failed. |
| refund.created | A refund was created. |
| refund.completed | A refund was completed. |
| refund.failed | A refund failed. |
Webhooks themselves
Section titled “Webhooks themselves”| Event | Sent when |
|---|---|
| webhook_endpoint.failing | Every retry of a delivery to an endpoint failed. |
| test.ping | You asked for a test event. |
cTpeWay can add new event types. Ignore the types your code does not know.
Verify the signature
Section titled “Verify the signature”Anyone who learns your webhook URL can send it a fake event. The signature proves that a delivery came from cTpeWay and was not changed on the way.
Each delivery has this header:
Ctpeway-Signature: t=<unix seconds>,v1=<signature>tis the time the delivery was signed, as Unix time in seconds.v1is an HMAC-SHA256, written in lowercase hexadecimal. The key is your endpoint’s signing secret, the whole string includingwhsec_. The message is the value oft, a full stop, and the raw request body:<t>.<raw body>.- During a secret rotation there are two
v1values, one for each secret:t=<unix seconds>,v1=<signature>,v1=<signature>.
To verify a delivery:
- Read the raw body, exactly as it was received, before any JSON parsing.
- Compute the HMAC-SHA256 of
<t>.<raw body>with your signing secret. - Compare it with
v1using a constant-time comparison. If the header has twov1values, one match is enough. - Reject the delivery if
tis more than 300 seconds away from the current time. This stops someone from replaying a delivery they captured. The SDK applies this limit by default.
import { CtpeWay, CtpeWaySignatureVerificationError, type WebhookEvent } from '@ctpeway/sdk';import express from 'express';
export const app = express();
// The signature covers the raw bytes of the body. Give this route express.raw(), and register it// before any app-wide express.json(), which would replace the bytes with a parsed object.app.post('/webhooks/ctpeway', express.raw({ type: 'application/json' }), (req, res) => { let event: WebhookEvent; try { event = CtpeWay.webhooks.constructEvent( req.body, req.headers['ctpeway-signature'], process.env.CTPEWAY_WEBHOOK_SECRET!, ); } catch (error) { if (error instanceof CtpeWaySignatureVerificationError) { res.status(400).send(error.message); return; } throw error; }
// Deliveries can repeat: make the handling idempotent, keyed on event.id. switch (event.type) { case 'invoice.paid': console.log(`invoice ${event.data.object.id} is paid`); break; case 'settlement.held': console.log(`top up the master wallet on ${event.data.object.network}`); break; default: break; } res.json({ received: true });});import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/** rawBody: the request body as a Buffer or string, exactly as received. */export function verifyCtpewaySignature(rawBody, header, secret) { const parts = String(header ?? '').split(',').map((part) => part.trim().split('=')); const timestamp = parts.find(([key]) => key === 't')?.[1]; const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value); if (!timestamp || signatures.length === 0) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest(); return signatures.some((signature) => { const given = Buffer.from(signature, 'hex'); return given.length === expected.length && timingSafeEqual(given, expected); });}Respond and handle repeats
Section titled “Respond and handle repeats”- Answer with a
2xxstatus quickly, then do slow work afterwards. Any other answer, or no answer, counts as a failed delivery and is retried. - Expect the same event more than once. Keep the
idof each event you have handled and skip repeats. - Do not rely on order. Two events about the same invoice can arrive in either order. When it matters, retrieve the object from the API to get its current state.
Retries
Section titled “Retries”When a delivery fails, cTpeWay tries again, waiting longer each time: after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, and then three more times, 24 hours apart. This schedule, and the email mentioned below, come from the v2 design and can change before launch.
If every attempt fails:
- the endpoint’s
statusbecomesfailing, and you are told by email; - cTpeWay emits the event webhook_endpoint.failing;
- deliveries to the endpoint continue, and it recovers on its first success.
An endpoint is enabled, failing or disabled. Nothing is sent to a disabled endpoint. Setting status back to enabled with PATCH/v1/webhook_endpoints/{id} also clears failing.
Replay an event
Section titled “Replay an event”cTpeWay keeps a log of every event, whether or not any endpoint received it. You can read it and send any event again, for example after fixing a bug in your handler.
| Endpoint | What it does |
|---|---|
| GET/v1/events | The event log, newest first. Add ?type=invoice.paid to see one type. |
| GET/v1/events/{id} | One event, exactly as it was delivered. |
| GET/v1/events/{id}/deliveries | Each delivery of an event, with its attempts. |
| GET/v1/webhook_endpoints/{id}/deliveries | Everything sent to one endpoint. |
| POST/v1/events/{id}/redeliver | Send an event again, to one endpoint or to every endpoint subscribed to its type. |
A redelivery has the same body as the original, byte for byte, and the same event id. So a handler that skips events it has already handled will skip a replay too. Clear your own record of that event first if you want it handled again.
The dashboard shows the same log under Developers → Webhooks, with the request, your server’s response, the retry schedule and a Resend button.
Rotate the signing secret
Section titled “Rotate the signing secret”POST/v1/webhook_endpoints/{id}/rotate_secret issues a new secret and returns it once.
For an overlap period, 24 hours unless you set overlap_hours, every delivery is signed with both the new and the previous secret, so the header has two v1 values. A server that still holds the old secret keeps verifying, and you can switch to the new one at any moment in that period.
Test your endpoint
Section titled “Test your endpoint”- Send a test event. POST/v1/test/webhooks/ping sends a
test.pingevent to one test endpoint or to all of them. It works in test mode only. The dashboard has the same action, Send test event. - Simulate a payment. A simulated payment sends the same events as a real one. See Testing.
- Check a saved delivery. The command-line tool can verify a payload you saved to a file: see Command-line tool.
If you used cTpeWay v1
Section titled “If you used cTpeWay v1”v2 does not send the v1 callback, and the callback URL of a v1 key is not used. Add a webhook endpoint as described on this page, verify its signature, and handle the v2 events. Moving from the v1 API shows which event replaces the v1 callback and where each of its fields is now.
Endpoints for managing webhooks
Section titled “Endpoints for managing webhooks”| Endpoint | What it does |
|---|---|
| POST/v1/webhook_endpoints | Create an endpoint. |
| GET/v1/webhook_endpoints | List your endpoints. |
| GET/v1/webhook_endpoints/{id} | Retrieve one endpoint. |
| PATCH/v1/webhook_endpoints/{id} | Change its URL, events or status. |
| DELETE/v1/webhook_endpoints/{id} | Delete an endpoint. |
| POST/v1/webhook_endpoints/{id}/rotate_secret | Rotate its signing secret. |

