Webhooks
Webhooks tell your server when something happens: a charge succeeds, a refund goes through, an unknown charge resolves. Instead of polling, you give us a URL, and we POST a signed event to it.
1. Add an endpoint
In the dashboard, open Developers → Webhooks and choose Add endpoint. Give it your URL and pick the events to send, or all of them. Or create it through the API:
curl https://api.corepayments.app/v1/webhook_endpoints \
-H "Authorization: Bearer $COREPAYMENTS_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/core-payments",
"enabled_events": ["charge.succeeded", "charge.failed", "charge.refunded"]
}' The response includes the endpoint’s signing secret, whsec_…. It’s shown once, so store it with your other secrets. In the dashboard, the endpoint’s page can reveal it again after you confirm it’s you.
- Pass
["*"]asenabled_eventsto get every event, including types added later. - Live endpoints must use
https. Test endpoints can usehttp, so you can point one at a tunnel to your machine. - Addresses on private networks, such as
localhostand10.0.0.0/8, are refused. - Each mode can have up to 16 endpoints. Test events only go to test endpoints, and live events only to live ones.
2. Verify the signature
Anyone can POST to your URL, so check every delivery’s signature before trusting it. Each request has a header like this:
CorePayments-Signature: t=1793840126,v1=5f8c1e... v1 is the hex HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with your endpoint’s secret. Compute it yourself, compare in constant time, and reject timestamps more than five minutes old so a captured request can’t be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
/**
* @param {string} body The raw request body, exactly as received.
* @param {string | null} header The CorePayments-Signature header.
* @param {string} secret Your endpoint's whsec_ secret.
*/
export function verifyCorePaymentsSignature(body, header, secret) {
const parts = Object.fromEntries(
(header ?? '').split(',').map((part) => part.trim().split('=', 2)),
)
const timestamp = Number(parts.t)
if (!Number.isInteger(timestamp) || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest()
const actual = Buffer.from(parts.v1, 'hex')
return actual.length === expected.length && timingSafeEqual(actual, expected)
} Use the raw body. If your framework parses JSON first, re-serializing it changes the bytes and the signature won’t match. In Express, for example, use express.raw({ type: 'application/json' }) on the webhook route:
app.post('/webhooks/core-payments', express.raw({ type: 'application/json' }), (req, res) => {
const body = req.body.toString('utf8')
if (!verifyCorePaymentsSignature(body, req.get('CorePayments-Signature'), process.env.WHSEC)) {
return res.sendStatus(400)
}
const event = JSON.parse(body)
// Hand the event to a queue or job, then answer.
res.sendStatus(200)
}) 3. Handle the event
Each delivery’s body is an event object. data.object is the object as it was right after the change. On updates, data.previous_attributes has the fields that changed, with their old values.
{
"id": "evt_Q2mV8xLb4TnR9pKs1WcZ7yHd",
"object": "event",
"livemode": false,
"type": "charge.succeeded",
"api_version": "2026-10-01",
"data": {
"object": {
"id": "ch_3kTq9Xb2VnR8sLp1MwQ4zYcD",
"object": "charge",
"amount": 2000,
"currency": "usd",
"status": "succeeded"
}
},
"request": { "id": "req_Q8aZ2nVx7LmT4pRk9sWc1bYe" },
"created": 1793840126
} - Answer fast. Return any
2xxwithin 10 seconds, then do the work. Slow handlers time out and get retried. - Expect duplicates. Delivery is at least once. Record the event
idyou’ve handled and skip repeats. - Expect any order. A
customer.updatedcan arrive before thecustomer.createdit follows. When order matters, retrieve the object for its current state. - Don’t follow up on redirects. A
3xxcounts as a failure; give us the final URL.
Retries
A delivery that doesn’t get a 2xx is retried with growing gaps: after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, then every 12 hours, for up to 3 days after the event. An endpoint whose deliveries have all failed for 3 days is turned off; turn it back on in the dashboard once it’s fixed.
The endpoint’s page in the dashboard lists recent deliveries with their status code, attempts and next retry, and Resend queues any event again. Events are kept for 30 days, and you can also fetch them with GET /v1/events to catch up after downtime.
Event types
New types can be added without notice. Ignore the ones you don’t handle.
| Type | When |
|---|---|
charge.succeeded | A charge took money. |
charge.authorized | A charge with capture: false was authorized and is waiting to be captured. |
charge.captured | An authorized charge was captured. |
charge.canceled | An uncaptured authorization was released. |
charge.failed | A charge was declined, or an unknown charge resolved as failed. |
charge.refunded | A charge was refunded, in full or in part. previous_attributes has the earlier amount_refunded. |
refund.created | A refund went through. |
refund.failed | A refund was refused. |
customer.created | A customer was created. |
customer.updated | A customer changed. previous_attributes has the old values. |
customer.deleted | A customer was deleted. |
payment_method.attached | A payment method was saved to a customer. |
payment_method.detached | A payment method was removed from its customer. |
product.created | |
product.updated | |
product.deleted | |
price.created | |
price.updated | |
invoice.created | |
invoice.updated | |
invoice.deleted | |
invoice.finalized | |
invoice.sent | |
invoice.payment_succeeded | |
invoice.payment_failed | |
invoice.paid | |
invoice.voided | |
invoice.marked_uncollectible |