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 ["*"] as enabled_events to get every event, including types added later.
  • Live endpoints must use https. Test endpoints can use http, so you can point one at a tunnel to your machine.
  • Addresses on private networks, such as localhost and 10.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 2xx within 10 seconds, then do the work. Slow handlers time out and get retried.
  • Expect duplicates. Delivery is at least once. Record the event id you’ve handled and skip repeats.
  • Expect any order. A customer.updated can arrive before the customer.created it follows. When order matters, retrieve the object for its current state.
  • Don’t follow up on redirects. A 3xx counts 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.

TypeWhen
charge.succeededA charge took money.
charge.authorizedA charge with capture: false was authorized and is waiting to be captured.
charge.capturedAn authorized charge was captured.
charge.canceledAn uncaptured authorization was released.
charge.failedA charge was declined, or an unknown charge resolved as failed.
charge.refundedA charge was refunded, in full or in part. previous_attributes has the earlier amount_refunded.
refund.createdA refund went through.
refund.failedA refund was refused.
customer.createdA customer was created.
customer.updatedA customer changed. previous_attributes has the old values.
customer.deletedA customer was deleted.
payment_method.attachedA payment method was saved to a customer.
payment_method.detachedA 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