Skip to main content
Looking for the endpoint details?

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.

ProcessYou get a webhook when
B2B account creationAn administrator approves or rejects the account request.
Individual creationAn administrator approves or rejects the onboarding.
Cardholder creationAn administrator approves or rejects the cardholder request.
IBAN settingsThe settings change is applied (or declined).
IBAN link updatesThe 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 POST a cardholder or a tenant, we answer you immediately — 200/202 for accepted, 400 for a validation error, 403 for 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"
}
}
FieldMeaning
isActiveThe master switch. While false, no webhook is attempted at all. Defaults to true.
urlYour HTTPS endpoint that accepts webhook POST requests.
Treat the secret key like a password

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:

{
"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​

HeaderValueWhat it is
Content-Typeapplication/jsonThe body is JSON.
X-Payload-Signature<base64>Our Ed25519 signature. See below.
X-Payload-Signature-Alged25519The 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
}
FieldMeaning
statusThe business outcome — success if the workflow ended successfully, failed if it was declined or failed.
typeThe event that happened. See Event types.
createdAtWhen we sent the webhook, ISO 8601.
webhookIdOur id for this outcome. Quote it in support requests, and use it to ignore repeats.
status is not the HTTP status

status: "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.

Do not skip verification

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:

SituationWhat you receive
The outcome was delivered successfullyNothing 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 outcomeA 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:

AttemptWhen it is sent
1Immediately
2About 1 minute later
3About 3 minutes later
4About 5 minutes later
5About 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.

typeWhen it firesstatus
b2b_account_createdA B2B account request was approved.success
b2b_account_declinedA B2B account request was rejected.failed
b2b_cardholder_approvedA cardholder request was approved.success
b2b_cardholder_declinedA cardholder request was rejected.failed
individual_onboarding_applicant_createdAn individual onboarding completed with a usable account.success
individual_onboarding_applicant_declinedAn individual onboarding ended without a usable account.failed
b2b_iban_settings_updatedAn IBAN settings change was applied or declined.success / failed
b2b_iban_linkedAn 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 }
}
FieldMeaning
eventThe event type — the same value as the body's type.
webhookUrlThe url we delivered to.
payloadSentThe exact body we sent.
responseStatusThe HTTP status your endpoint returned, or null if it never answered.
responseBodyThe body your endpoint returned.
errorThe error details, when the delivery failed.
createdAtWhen 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.isActive still true?
  • Does webhooks.url still 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/secret and configure your receiver to verify the raw request body with that exact value?
  • Was the workflow actually finished? An event still in PENDING sends 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 responseStatus in your delivery history. If it is not 2xx and 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 2xx and you still see two rows, check whether the workflow produced distinct outcomes. Retries reuse the same webhookId; make your handler idempotent on that value.
  • Two requests with the same webhookId are the same outcome, re-sent after a failed attempt. Two requests with different webhookId values 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's status is the business outcome, not the delivery result.
  • Check type to confirm which event this is; the event-specific fields differ per type.