Get notified when a payment or payout changes, and verify that the notification comes from pensopay.
A callback is an HTTP request we send to your server when something happens to a payment or a
payout, for example when it is authorized or captured. Use callbacks to update your order
system without polling the API.
Receiving callbacks
Set callback_url when you create a payment, payout or subscription. The URL must use https://.
We send a POST request to that URL with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | pensopay callback/2.0 |
Pensopay-Signature | The signature of the body. See Verifying callbacks. |
For a subscription, every callback for the subscription's mandates and payments is sent to the
subscription's callback_url.
The callback body
{
"type": "payment",
"event": "payment.authorized",
"message": "",
"created_at": "2026-09-01T10:02:34.570769Z",
"resource_id": 1000000,
"resource": {
"id": 1000000,
"order_id": "1234",
"type": "payment",
"state": "authorized",
"amount": 500,
"captured": 0,
"refunded": 0,
"currency": "DKK",
"testmode": false,
"variables": {
"key": "value"
},
"created_at": "2026-09-01T10:02:30Z"
}
}| Field | Type | Description |
|---|---|---|
type | string | The kind of resource: payment, recurring, mandate or payout. |
event | string | What happened, as <type>.<event>. See Events. |
message | string | Empty when the action succeeded. When it failed, a description of why. |
error_code | integer | Only on authorize_failed. Why the authorization was declined. See Declined authorizations. |
created_at | string | When the callback was created, in UTC (ISO 8601). |
resource_id | integer | The id of the payment or payout. |
resource | object | The payment or payout, in the same format as the API returns it. The example above is shortened. |
Events
type tells you which kind of resource the callback is about:
| Type | Resource |
|---|---|
payment | A one-off payment. |
recurring | A payment made with a subscription. |
mandate | The payment that activates a subscription's mandate. |
payout | A payout. |
Payment events
These apply to payment, recurring and mandate, for example payment.captured or
recurring.refunded.
| Event | Sent when |
|---|---|
authorized | The payment is authorized. |
authorize_failed | An authorization was declined. |
captured | A capture succeeded. |
capture_failed | A capture failed. |
refunded | A refund succeeded. |
refund_failed | A refund failed. |
canceled | The payment is canceled. |
cancel_failed | A cancel failed. |
Declined authorizations
When an authorization is declined, you receive an authorize_failed callback with an
error_code that tells you why:
{
"type": "payment",
"event": "payment.authorize_failed",
"message": "Insufficient funds",
"error_code": 1000,
"created_at": "2026-09-01T10:02:34.570769Z",
"resource_id": 1000000,
"resource": {
"id": 1000000,
"order_id": "1234",
"state": "pending",
"amount": 500,
"currency": "DKK"
}
}error_code | message | Meaning |
|---|---|---|
1000 | Insufficient funds | The card does not have enough funds. |
1500 | Amount limit exceeded | The amount is over a limit on the card. |
3000 | Suspected fraud | The payment was declined as possible fraud. |
9999 | Undisclosed error | Any other reason, or the acquirer did not respond. |
The codes are the same no matter which acquirer processed the card.
A declined authorization does not change the payment's state. The payment stays pending, so
the customer can try again, and you receive authorized once a payment succeeds.
Payout events
| Event | Sent when |
|---|---|
payout.processed | The payout is processed. |
payout.process_failed | The payout failed. |
payout.canceled | The payout is canceled. |
payout.cancel_failed | A cancel failed. |
Responding to callbacks
Respond with an HTTP status code from 200 to 299 within 15 seconds. Anything else, including
a timeout, counts as a failed delivery.
A failed delivery is retried once an hour. We try at most 12 times in total, so the last
attempt is made about 11 hours after the first. After that, the callback is not sent again.
Handle the same callback more than onceA callback can reach you more than once, for example if your server processed it but the
response timed out. A repeated delivery has exactly the same body as the first, so compare
the raw body to make sure you only act on each callback once.Do not use
resource_idandeventalone for this. A payment can be captured or refunded
more than once, and each of those sends its own callback with the sameresource_idand
event.
Verifying callbacks
Check every callback before you trust it. The Pensopay-Signature header holds an HMAC-SHA256
of the raw request body, written as lowercase hex. The key is your account's private key, which
you find in the pensopay app.
To verify a callback:
- Read the request body exactly as it was received, before you parse it as JSON.
- Calculate the HMAC-SHA256 of the body, using your private key.
- Compare the result with the
Pensopay-Signatureheader, using a constant-time comparison.
Use the raw bodyDo not parse the JSON and serialize it again before you calculate the signature. The result
can differ from the body we signed, and the check then fails.
<?php
$body = file_get_contents('php://input');
$privateKey = 'your-private-key';
$expected = hash_hmac('sha256', $body, $privateKey);
$received = $_SERVER['HTTP_PENSOPAY_SIGNATURE'] ?? '';
if (hash_equals($expected, $received)) {
// The callback is from pensopay.
} else {
// Reject the callback.
}import crypto from 'node:crypto';
import express from 'express';
const app = express();
const privateKey = 'your-private-key';
// express.raw keeps the body as the exact bytes we sent.
app.post('/callback', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', privateKey).update(req.body).digest('hex');
const received = req.get('Pensopay-Signature') ?? '';
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) {
return res.sendStatus(401);
}
const callback = JSON.parse(req.body);
// The callback is from pensopay.
res.sendStatus(200);
});