Skip to content

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.

  1. Write a route on your server that accepts a POST with a JSON body.

  2. 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"]
    }'

    Use "events": ["*"] to receive every event.

  3. Store the signing secret. The response contains secret, which starts with whsec_. It is returned only this once. You need it to verify deliveries.

  4. 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.

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.

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.

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

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>
  • t is the time the delivery was signed, as Unix time in seconds.
  • v1 is an HMAC-SHA256, written in lowercase hexadecimal. The key is your endpoint’s signing secret, the whole string including whsec_. The message is the value of t, a full stop, and the raw request body: <t>.<raw body>.
  • During a secret rotation there are two v1 values, one for each secret: t=<unix seconds>,v1=<signature>,v1=<signature>.

To verify a delivery:

  1. Read the raw body, exactly as it was received, before any JSON parsing.
  2. Compute the HMAC-SHA256 of <t>.<raw body> with your signing secret.
  3. Compare it with v1 using a constant-time comparison. If the header has two v1 values, one match is enough.
  4. Reject the delivery if t is 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 });
});
  • Answer with a 2xx status 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 id of 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.

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 status becomes failing, 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.

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.

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.

  • Send a test event. POST/v1/test/webhooks/ping sends a test.ping event 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.

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.

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.