Callback

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:

HeaderValue
Content-Typeapplication/json
User-Agentpensopay callback/2.0
Pensopay-SignatureThe 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"
  }
}
FieldTypeDescription
typestringThe kind of resource: payment, recurring, mandate or payout.
eventstringWhat happened, as <type>.<event>. See Events.
messagestringEmpty when the action succeeded. When it failed, a description of why.
error_codeintegerOnly on authorize_failed. Why the authorization was declined. See Declined authorizations.
created_atstringWhen the callback was created, in UTC (ISO 8601).
resource_idintegerThe id of the payment or payout.
resourceobjectThe 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:

TypeResource
paymentA one-off payment.
recurringA payment made with a subscription.
mandateThe payment that activates a subscription's mandate.
payoutA payout.

Payment events

These apply to payment, recurring and mandate, for example payment.captured or
recurring.refunded.

EventSent when
authorizedThe payment is authorized.
authorize_failedAn authorization was declined.
capturedA capture succeeded.
capture_failedA capture failed.
refundedA refund succeeded.
refund_failedA refund failed.
canceledThe payment is canceled.
cancel_failedA 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_codemessageMeaning
1000Insufficient fundsThe card does not have enough funds.
1500Amount limit exceededThe amount is over a limit on the card.
3000Suspected fraudThe payment was declined as possible fraud.
9999Undisclosed errorAny 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

EventSent when
payout.processedThe payout is processed.
payout.process_failedThe payout failed.
payout.canceledThe payout is canceled.
payout.cancel_failedA 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 once

A 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_id and event alone for this. A payment can be captured or refunded
more than once, and each of those sends its own callback with the same resource_id and
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:

  1. Read the request body exactly as it was received, before you parse it as JSON.
  2. Calculate the HMAC-SHA256 of the body, using your private key.
  3. Compare the result with the Pensopay-Signature header, using a constant-time comparison.
🚧

Use the raw body

Do 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);
});