The list of webhook endpoints → has every request body, response body, and validation rule for the public key, the secret, the event subscriptions, and the delivery history.
Webhooks
A webhook lets Paynovus tell you when something happened in your account, without you polling.
The important thing to understand up front: a webhook is not a request-response API. When one of your workflows finishes, we POST a JSON body to a URL you give us. Your endpoint answers with a 2xx and the event is done.
When you get a webhook
We send a webhook when a business process reaches its final outcome — and only then.
| Process | You get a webhook when |
|---|---|
| B2B account creation | An administrator approves or rejects the account request. |
| Individual creation | An administrator approves or rejects the onboarding. |
| Cardholder creation | An administrator approves or rejects the cardholder request. |
| IBAN settings | The settings change is applied (or declined). |
| IBAN link updates | The IBAN is linked (or the request is declined). |
What does not trigger a webhook
This is the part worth being precise about, because it is easy to assume otherwise:
- Your own API call never triggers a webhook. When you
POSTa cardholder or a tenant, we answer you immediately —200/202for accepted,400for a validation error,403for an authorization failure. That answer is the response to your request; the business process is still running. No webhook fires. - Intermediate workflow states never trigger a webhook. A cardholder that is sitting in
PENDING, waiting for a second administrator, has not reached a final outcome. Nothing is sent. - Only the final outcome is sent. Approval → one webhook. Rejection → one webhook. Never both for the same workflow. Temporary delivery failures can cause multiple attempts with the same
webhookId.
So: if you are waiting on a webhook, you are waiting on a decision — either an administrator's, or an automatic process's.
Configuring your endpoint
You have one endpoint (url) and one secret. Every event type goes to that url; what happened travels in the request body's type field.
Your Paynovus administrator sets the url and master switch on your partner record; you set the secret yourself (see below):
{
"webhooks": {
"isActive": true,
"url": "https://your-domain.example/webhooks/cip"
}
}
| Field | Meaning |
|---|---|
isActive | The master switch. While false, no webhook is attempted at all. Defaults to true. |
url | Your HTTPS endpoint that accepts webhook POST requests. |
Whoever holds the secret can forge webhooks that look like ours. Keep it out of source control and out of
client-side code. If it leaks, rotate it yourself with PUT /api/partner/webhooks/secret.
Turning individual events on and off
Besides the master switch, each event type has its own on/off switch. A webhook is sent only when both are on: first the master switch, then the event's own switch. An event you never touched is on, so new event types reach you without any change.
You manage the per-event switches yourself:
GET /api/partner/webhooks/eventslists every event and whether it is on.PATCH /api/partner/webhooks/eventsswitches one or several events at once.
{
"events": [
{ "event": "b2b_cardholder_approved", "isActive": false },
{ "event": "b2b_iban_linked", "isActive": true }
]
}
Events you do not mention keep their state. Switching an event off does not touch the master switch or your url; a webhook skipped this way is not queued and will not be sent later.
The secret key
You can set or rotate it yourself with PUT /api/partner/webhooks/secret. Send { "secretKey": "..." } (8-200 characters) for your own value, or send no body and we generate one and return it once. The field name must be exactly secretKey; unknown fields are rejected. The response includes secretKeyGenerated so you can confirm whether we used your value or generated one. It is write-only: we cannot show it again, so store it when you set it. A new secret applies to every webhook sent after the call, with no grace period for the old one.
If you choose your own, pick something long and random (32+ characters from a password manager is ideal).
We store it encrypted and never send it back to you over any API, and we never put it in a webhook. We only sign a hash of it (see below), so you can verify that a webhook is ours and was made for your secret.
How a webhook reaches you
We issue a POST to your configured URL with a JSON body.
Headers
| Header | Value | What it is |
|---|---|---|
Content-Type | application/json | The body is JSON. |
X-Payload-Signature | <base64> | Our Ed25519 signature. See below. |
X-Payload-Signature-Alg | ed25519 | The signature algorithm. |
X-Payload-Key-Id | <hex> | Which of our keys signed it. |
The body is not encrypted — it is signed.
The body
Every body has the same envelope. What varies is type and the event-specific fields alongside it.
{
"status": "success", // the business outcome: "success" | "failed"
"type": "b2b_cardholder_approved", // the event that happened
"createdAt": "2026-01-15T10:30:00.000Z", // when we sent it
"webhookId": "6adf2b5bfe545da555d81245" // our id for this delivery
// ...event-specific fields, see the event table below
}
| Field | Meaning |
|---|---|
status | The business outcome — success if the workflow ended successfully, failed if it was declined or failed. |
type | The event that happened. See Event types. |
createdAt | When we sent the webhook, ISO 8601. |
webhookId | Our id for this outcome. Quote it in support requests, and use it to ignore repeats. |
status is not the HTTP statusstatus: "failed" means the business outcome was a rejection — and we still delivered the webhook
successfully (your endpoint returned 2xx). The HTTP status of the delivery is visible in your
delivery history, not in this body.
Verifying the signature
Always verify the signature before trusting a webhook. It is what proves the request came from us and was not altered in transit.
There is one scheme and one secret. We sign, with a private key only we hold, the message:
sha256hex(yourSecret) + "." + rawBody
Fetch our public key once with GET /api/partner/webhooks/public-key, cache it by keyId, and verify:
const crypto = require('crypto');
function verify(rawBody, signatureB64, secret, publicKeyPem) {
const secretHash = crypto.createHash('sha256').update(secret, 'utf8').digest('hex');
const message = Buffer.from(`${secretHash}.${rawBody}`, 'utf8');
return crypto.verify(null, message, publicKeyPem, Buffer.from(signatureB64, 'base64'));
}
A valid signature proves three things: it came from us (only we hold the private key), the body is unaltered, and it was made for your secret. Someone who learns your secret still cannot forge a webhook, because they cannot sign. If X-Payload-Key-Id is one you have not seen, re-fetch the public key. If we have no signing key configured on an environment, no webhooks are sent from it.
The one rule that trips people up
Verify the raw body, not a re-serialized version of it.
We sign the bytes that go on the wire. If you parse the JSON and re-serialize it, key order or number formatting may differ by a single byte, and verification will fail even though nothing is wrong.
Most frameworks give you the raw body. In Express, for example:
const express = require('express');
const app = express();
// Capture the raw bytes BEFORE any JSON parsing.
app.post('/webhooks/cip', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const signature = req.headers['x-payload-signature'];
if (!signature || !verify(rawBody, signature, process.env.CIP_WEBHOOK_SECRET, process.env.CIP_PUBLIC_KEY_PEM)) {
return res.status(400).json({ error: 'bad signature' });
}
const event = JSON.parse(rawBody);
// ...handle the event...
res.status(200).json({ received: true });
});
If your framework parses JSON before you can get the raw bytes, configure it to keep them — every framework has an option for this.
Verification happens on your side, so we cannot enforce it. Without the signature check, anyone who learns your URL can POST to it and you will treat their payload as a real event. The body is signed, not encrypted: it is readable by anyone, so reading it proves nothing.
Replays: a valid signature stays valid, so make your handler idempotent and ignore a webhookId or entity you have already processed.
Responding
Answer 2xx as soon as you have the event. Do not do your downstream processing synchronously.
If you return a non-2xx status, or the request times out, we treat the delivery as failed. Temporary failures are retried automatically, so a slow endpoint can receive the same event more than once.
Retries use the same delivery record and webhookId, but each retry is another HTTP request. A timeout can also mean your endpoint received the first attempt even though we did not receive its response. Make your handler idempotent by recording processed webhookId values.
How often you will hear about an outcome
We guarantee that one final outcome produces one webhook. If a workflow is approved, you get one b2b_account_created; if it is rejected, one b2b_account_declined — never both, and never the same outcome twice because a job was retried or replayed.
Internally, every outcome gets an identity derived from the event and the business object it concerns. We use that identity to recognise a repeat: a second attempt at the same outcome updates the delivery record we already have instead of creating a new one.
What that means for you:
| Situation | What you receive |
|---|---|
| The outcome was delivered successfully | Nothing further — the repeat is ignored. |
| The outcome was never delivered (timeout, error) | The same webhookId again, once we retry. |
| The workflow produced a genuinely different outcome | A new webhook, with its own webhookId. |
So the webhookId is the identity of the outcome, not of the HTTP request. Two requests carrying the same webhookId are the same outcome, and your handler should treat the second as a repeat. This is also why your delivery history shows a row per attempt while the outcome itself stays single.
Retries
If we cannot deliver a webhook because of a temporary problem, we try again automatically. The first attempt is sent immediately, and the next attempts are spaced out over time:
| Attempt | When it is sent |
|---|---|
| 1 | Immediately |
| 2 | About 1 minute later |
| 3 | About 3 minutes later |
| 4 | About 5 minutes later |
| 5 | About 10 minutes later |
There are 5 attempts in total. Retrying stops as soon as your endpoint accepts the webhook. After the fifth unsuccessful attempt, the delivery is no longer retried automatically.
We retry temporary delivery problems, such as a timeout, an unreachable endpoint, or a temporary server error. A rejection that indicates the request itself is wrong — for example, most 4xx responses — is not retried because sending the same request again would normally produce the same result.
You can review every attempt, including whether it succeeded, in your delivery history. If your endpoint may have received a webhook even though we saw a timeout, use the webhookId to avoid processing the same event twice.
Event types
The type field tells you which event this is.
type | When it fires | status |
|---|---|---|
b2b_account_created | A B2B account request was approved. | success |
b2b_account_declined | A B2B account request was rejected. | failed |
b2b_cardholder_approved | A cardholder request was approved. | success |
b2b_cardholder_declined | A cardholder request was rejected. | failed |
individual_onboarding_applicant_created | An individual onboarding completed with a usable account. | success |
individual_onboarding_applicant_declined | An individual onboarding ended without a usable account. | failed |
b2b_iban_settings_updated | An IBAN settings change was applied or declined. | success / failed |
b2b_iban_linked | An IBAN link request was applied or declined. | success / failed |
Event-specific fields
These sit alongside the envelope fields.
b2b_account_created / b2b_account_declined
{
"b2bAccountId": "6a9ab39e1496d30c5d392909",
"companyName": "Example Company Ltd"
}
b2b_cardholder_approved / b2b_cardholder_declined
{
"cardholderId": "6adf2243ea98a84e4bdcd73f",
"b2bAccountId": "6a9ab39e1496d30c5d392909",
"clientID": "RYVL-1720_5",
"declineReason": "..." // decline events only
}
individual_onboarding_applicant_created / individual_onboarding_applicant_declined
{
"individualOnboardingId": "6adf2243ea98a84e4bdcd73f",
"IBAN": "BG18RZBB91550123456789",
"cardKey": "KDRYV0XXXXXXXXXX" // created events only
}
b2b_iban_settings_updated
{
"b2bAccountId": "6a9ab39e1496d30c5d392909",
"IBAN": "BG18RZBB91550123456789",
"settings": { "...": "the settings that were applied" }
}
b2b_iban_linked
{
"b2bAccountId": "6a9ab39e1496d30c5d392909",
"IBAN": "BG18RZBB91550123456789"
}
Your delivery history
GET /logs/webhooks returns every webhook we sent to you, newest first, one row per delivery attempt.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.card-management-staging.paynovus.com/api/partner/logs/webhooks?page=1&limit=50"
{
"success": true,
"count": 2,
"data": [
{
"_id": "6adf2f5bfe545da555d81245",
"event": "b2b_cardholder_approved",
"webhookUrl": "https://your-domain.example/webhooks/cip",
"payloadSent": { "status": "success", "type": "b2b_cardholder_approved" },
"responseStatus": 200,
"responseBody": { "received": true },
"error": null,
"createdAt": "2026-01-15T10:30:00.000Z"
}
],
"pagination": { "total": 2, "page": 1, "limit": 50, "pages": 1, "hasNextPage": false, "hasPrevPage": false }
}
| Field | Meaning |
|---|---|
event | The event type — the same value as the body's type. |
webhookUrl | The url we delivered to. |
payloadSent | The exact body we sent. |
responseStatus | The HTTP status your endpoint returned, or null if it never answered. |
responseBody | The body your endpoint returned. |
error | The error details, when the delivery failed. |
createdAt | When the attempt was made. |
This listing is scoped to your partner: you only ever see the webhooks sent to you. The signing secret is never included.
Your history survives a configuration change. If you change your webhook url, the events delivered to the old url remain in this list — they are recorded against your partner, not against the address they happened to go to.
Testing your endpoint
Configure webhooks.url as an HTTPS endpoint that you operate and that can receive requests from our service. For local development, expose your own local receiver through a secure HTTPS tunnel and configure the resulting HTTPS URL; do not use a localhost URL, which is not reachable from our service.
Before testing, set a partner-specific secret with PUT /api/partner/webhooks/secret, implement the raw-body signature verification described above, and return a 2xx response after accepting the event. Then complete a workflow that produces a final outcome and inspect your endpoint's request logs and your partner delivery history. The request body is signed, not encrypted, and should be handled as sensitive data.
Troubleshooting
No webhook arrives at all.
- Is
webhooks.isActivestilltrue? - Does
webhooks.urlstill point where you think? A url you changed is a url we no longer call. - Is the event switched off? Check
GET /api/partner/webhooks/events. - Did you set a partner-specific secret with
PUT /api/partner/webhooks/secretand configure your receiver to verify the raw request body with that exact value? - Was the workflow actually finished? An event still in
PENDINGsends nothing.
You get the webhook but it fails signature verification.
- You are probably signing a re-serialized body. Sign the raw bytes.
- The secret does not match what is configured. Check for a trailing newline or a stale copy in your environment variables.
You get the same event twice.
- Check
responseStatusin your delivery history. If it is not2xxand the failure was temporary, we may have retried it — the first attempt timed out or errored, not that we announced the outcome twice. - If it is
2xxand you still see two rows, check whether the workflow produced distinct outcomes. Retries reuse the samewebhookId; make your handler idempotent on that value. - Two requests with the same
webhookIdare the same outcome, re-sent after a failed attempt. Two requests with differentwebhookIdvalues are two different outcomes — for example an approval and a later decline — and both are legitimate.
The payload is not what you expected.
- Check
status— the envelope'sstatusis the business outcome, not the delivery result. - Check
typeto confirm which event this is; the event-specific fields differ per type.