Webhooks

In A Nutshell
In a nutshell

Webhooks allow you to set up a notification system that can be used to receive updates on certain requests made to the Paystack API.

Introduction

Generally, when you make a request to an API endpoint, you expect to get a near-immediate response. However, some requests may take a long time to process, which can lead to timeout errors. To prevent a timeout error, a pending response is returned. Since your records need to be updated with the final state of the request, you need to either:

  1. Make a request for an update (popularly known as polling) or,
  2. Listen to events by using a webhook URL.
Helpful Tip

We recommend that you use webhook to provide value to your customers over using callbacks or polling. With callbacks, we don't have control over what happens on the customer's end. Neither do you. Callbacks can fail if the network connection on a customer's device fails or is weak or if the device goes off after a transaction.

Polling vs webhooks

Image showing a comparison between polling and webhooks

Polling requires making a GET request at regular intervals to get the final status of a request. For example, when a customer makes a payment for a transaction, you keep making a request for the transaction status until you get a successful transaction status.

With webhooks, the resource server, Paystack in this case, sends updates to your server when the status of your request changes. The change in status of a request is known as an event. You’ll typically listen to these events on a POST endpoint called your webhook URL.

The table below highlights some differences between polling and webhooks:

PollingWebhooks
Mode of updateManualAutomatic
Rate limitingYesNo
Impacted by scalingYesNo

Create a webhook URL

A webhook URL is simply a POST endpoint that a resource server sends updates to. The URL needs to parse a JSON request and return a 200 OK:

1// Using Express
2app.post("/my/webhook/url", function(req, res) {
3 // Retrieve the request's body
4 const event = req.body;
5 // Do something with event
6 res.send(200);
7});

When your webhook URL receives an event, it needs to parse and acknowledge the event. Acknowledging an event means returning a 200 OK in the HTTP header. Without a 200 OK in the response header, events are sent for the next 72 hours:

  • In live mode, webhook events are sent every 3 minutes for the first 4 tries, then retried hourly for the next 72 hours
  • In test mode, webhook events are sent hourly for 10 hours, with a request timeout of 30 seconds.
Avoid long-running tasks

If you have extra tasks in your webhook function, you should return a 200 OK response immediately. Long-running tasks lead to a request timeout and an automatic error response from your server. Without a 200 OK response, the retry as described in the proceeding paragraph.

Verify event origin

Since your webhook URL is publicly available, you need to verify that events originate from Paystack and not a bad actor. There are two ways to ensure events to your webhook URL are from Paystack:

  1. Signature validation
  2. IP whitelisting

Signature validation

Events sent from Paystack carry a signature header. Verify it on the raw request body before you process the event. Paystack signs events with your webhook signing secret (whsec_). You manage it on your dashboard under Developers > API configuration.

The header looks like this:

1Paystack-Signature: t=1760270400,v1=whk_3f9a1c2e:9d1b...e47a

t is the time Paystack signed the event, in Unix seconds. Each v1 is <key id>:<signature>. The key id starts with whk_. The signature is a lowercase hex HMAC SHA512, 128 characters long. An event can carry more than one v1, so check every one. Ignore any other fields in the header.

If your events don't carry this header yet, your integration has no active signing secret for this mode. Generate one on your dashboard, and keep your secret key and keep verifying x-paystack-signature until your events carry it.

To verify the header:

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Split the header on ,. Take the t value and every v1 value. Split each v1 value on its first : into the key id and the signature.
  3. Reject the event if t is more than 300 seconds before or after your server's time. This stops anyone from replaying an old event. Keep your server clock synced.
  4. Compute an HMAC SHA512 of t, then ., then the raw body, using your whole signing secret, including whsec_, as the key.
  5. Compare your result with each v1 signature using a constant-time comparison. The event is genuine if any of them matches.
  6. Return 401 if nothing matches. If one matches, return 200 OK, then process the event.
1const crypto = require('crypto');
2const express = require('express');
3
4const TOLERANCE_SECONDS = 300;
5
6// Returns the key id (kid) of the signature that matched, or null.
7function verifyPaystackSignature(rawBody, header, secrets, now = Math.floor(Date.now() / 1000)) {
8 if (!Buffer.isBuffer(rawBody) || typeof header !== 'string') return null;
9
10 let t = null;
11 const entries = [];
12 for (const part of header.split(',')) {
13 const i = part.indexOf('=');
14 if (i === -1) continue;
15 const key = part.slice(0, i).trim();
16 const value = part.slice(i + 1).trim();
17 if (key === 't') {
18 if (t !== null || !/^[0-9]+$/.test(value)) return null;
19 t = Number(value);
20 } else if (key === 'v1') {
21 const j = value.indexOf(':');
22 if (j !== -1) entries.push({ kid: value.slice(0, j), hex: value.slice(j + 1) });
23 }
24 }
25 if (t === null || entries.length === 0) return null;
26 if (Math.abs(now - t) > TOLERANCE_SECONDS) return null;
27
28 for (const secret of secrets) {
29 const expected = crypto.createHmac('sha512', secret).update(t + '.').update(rawBody).digest();
30 for (const { kid, hex } of entries) {
31 if (!/^[0-9a-f]{128}$/.test(hex)) continue;
32 if (crypto.timingSafeEqual(expected, Buffer.from(hex, 'hex'))) return kid;
33 }
34 }
35 return null;
36}
37
38const app = express();
39
40// express.raw keeps req.body as the exact bytes Paystack sent. Register this route before any
41// app.use(express.json()): a JSON parser that runs first consumes the body and every check fails.
42app.post('/my/webhook/url', express.raw({ type: 'application/json' }), (req, res) => {
43 const secret = process.env.PAYSTACK_WEBHOOK_SECRET; // whsec_...
44 if (!secret) return res.sendStatus(500);
45 const kid = verifyPaystackSignature(req.body, req.get('paystack-signature'), [secret]);
46 if (!kid) return res.sendStatus(401);
47
48 res.sendStatus(200);
49 const event = JSON.parse(req.body.toString('utf8'));
50 // Do something with event
51});
Use the raw request body

If your framework parses the JSON and you serialize it again, spacing, number formats such as 1.50, and escaped characters can change, and the signature no longer matches.

Paystack signs retried and resent events again with a new t, so the 300-second window doesn't reject them.

To replace your signing secret, rotate it on your dashboard and choose how long the current secret keeps working: 1 hour, 24 hours, 3 days, or 7 days. During that overlap, events carry two v1 signatures, one for each secret. Deploy the new secret to your server before the overlap ends.

If your signing secret leaks, revoke it on your dashboard. It stops signing within a minute, with no overlap. Then generate a new secret and deploy it to your server. Paystack retries events that fail your check in between, and signs each retry with the new secret.

X-Paystack-Signature

Check your webhooks before you delete your secret key

If your webhooks don't carry Paystack-Signature yet, don't delete your secret key. If you do, Paystack stops sending you webhooks. Before you delete it, confirm that your webhooks carry Paystack-Signature and that your server verifies it.

While your integration has a secret key, events also carry the x-paystack-signature header. Paystack stops sending it once you delete your secret key, so verify Paystack-Signature instead once your events carry it.

The value of x-paystack-signature is an HMAC SHA512 signature of the raw request body, signed using your secret key. It has no timestamp. To verify it:

1const crypto = require('crypto');
2const secret = process.env.SECRET_KEY;
3// Using Express
4app.use(express.json({
5 verify: (req, res, buf) => {
6 req.rawBody = buf;
7 }
8}));
9
10app.post("/my/webhook/url", function(req, res) {
11 //validate event
12 const hash = crypto.createHmac('sha512', secret).update(req.rawBody).digest();
13 const signature = req.headers['x-paystack-signature'] || '';
14 if (!/^[0-9a-f]{128}$/.test(signature) || !crypto.timingSafeEqual(hash, Buffer.from(signature, 'hex'))) {
15 return res.sendStatus(401);
16 }
17 // Retrieve the request's body
18 const event = req.body;
19 // Do something with event
20 res.sendStatus(200);
21});

IP whitelisting

With this method, you only allow certain IP addresses to access your webhook URL while blocking out others. Paystack will only send webhooks from the following IP addresses:

  1. 52.31.139.75
  2. 52.49.173.169
  3. 52.214.14.220

You should whitelist these IP addresses and consider requests from other IP addresses a counterfeit.

Whitelisting is domain independent

The IP addresses listed above are applicable to both test and live environments. You can whitelist them in your staging and production environments.

Go live checklist

Now that you’ve successfully created your webhook URL, here are some ways to ensure you get a delightful experience:

  1. Add the webhook URL on your Paystack dashboard
  2. Ensure your webhook URL is publicly available (localhost URLs can't receive events)
  3. If using .htaccess remember to add the trailing / to the URL
  4. Test your webhook to ensure you’re getting the JSON body and returning a 200 OK HTTP response
  5. If your webhook function has long-running tasks, you should first acknowledge receiving the webhook by returning a 200 OK before proceeding with the long-running tasks
  6. If we don’t get a 200 OK HTTP response from your webhooks, we flagged it as a failed attempt
  7. In the live mode, failed attempts are retried every 3 minutes for the first 4 tries, then retried hourly for the next 72 hours
  8. In the test mode, failed attempts are retried hourly for the next 10 hours. The timeout for each attempt is 30 seconds.

View and resend events

We record every webhook event we attempt to deliver to your webhook URL, including its payload and the response your server returned. Use the Webhook EventsAPI to:

  • List and filter your event history by category, event type, status, webhook endpoint, or date
  • Look up a specific event using its id
  • Resend a set of events, or every event matching a filter, to your current webhook URL

We resend each event to the current URL of the destination it was first sent to: the endpoint in its webhook_url_code, or your dashboard webhook URL if webhook_url_code is null.

Resending isn't idempotent. If your server already processed an event, a resend delivers it again, so your handler must ignore events it has already processed. Before you resend every event matching a filter, pass preview=true to check how many events match.

Webhook endpoints

Feature Availability

Webhook endpoints are in pilot. To request access, email [email protected].

Webhook endpoints let you send events to more than one URL, and choose which events each URL receives. For example, you can send charge.success to your order service and transfer.success to your payouts service. Paystack sends each event to every enabled endpoint that's subscribed to it.

Test and live mode have separate endpoints. Use your test secret key to manage test endpoints, and your live secret key to manage live ones. A restricted key needs the webhooks:read scope to list and fetch endpoints, and webhooks:write to change or ping them.

Create an endpoint

An endpoint's URL must use https and meet the same requirements as any webhook URL: it must be publicly reachable, verify the signature of each event, and return a 200 OK.

To create an endpoint:

  1. Call List Available EventsAPI to get the names of the events you can subscribe to.
  2. Call Create Webhook EndpointAPI with a name, the URL, and the events to send to it.
  3. Store the webhook_url_code from the response. You use it to update, test, and delete the endpoint.
Show Response
1#!/bin/sh
2url="https://api.paystack.co/integration/webhooks/endpoints"
3authorization="Authorization: Bearer YOUR_SECRET_KEY"
4content_type="Content-Type: application/json"
5data='{
6 "name": "Order fulfillment",
7 "url": "https://example.com/webhooks/paystack",
8 "events": [
9 "charge.success",
10 "charge.failed"
11 ]
12}'
13
14curl "$url" -H "$authorization" -H "$content_type" -d "$data" -X POST
1{
2 "status": true,
3 "message": "Webhook endpoint created",
4 "data": {
5 "webhook_url_code": "WHK_1a2b3c4d",
6 "name": "Order fulfillment",
7 "webhook_url": "https://example.com/webhooks/paystack",
8 "enabled": true,
9 "events": [
10 "charge.success",
11 "charge.failed"
12 ],
13 "event_count": 2
14 }
15}

To subscribe an endpoint to every event, send events as ["*"]. Paystack subscribes the endpoint to every event available at that moment, and the response lists them. Events that Paystack adds later aren't included, so send ["*"] again in an updateAPI to pick them up.

Test an endpoint

Ping an endpoint to check that Paystack can reach it before you rely on it. Ping Webhook EndpointAPI sends a signed test event with event set to ping to the endpoint's URL, and returns your server's response:

Show Response
1#!/bin/sh
2url="https://api.paystack.co/integration/webhooks/endpoints/WHK_1a2b3c4d/ping"
3authorization="Authorization: Bearer YOUR_SECRET_KEY"
4
5curl "$url" -H "$authorization" -X POST
1{
2 "status": true,
3 "message": "Ping sent",
4 "data": {
5 "ok": true,
6 "http_status": 200,
7 "latency_ms": 143
8 }
9}

Use data.ok and data.http_status to work out what happened:

ResultMeaningWhat to do
ok is trueYour server returned a 2xx status.Nothing. The endpoint is ready.
ok is false and http_status is a numberYour server received the ping and returned an error status.Check your server logs for the request. Make sure your handler verifies the signature for the mode you're in, and returns a 200 OK for events it doesn't process, including ping.
ok is false and http_status is nullPaystack couldn't get a response from your URL.Check that the URL's domain resolves and the server is running, the server has a valid TLS certificate, your firewall allows Paystack's IP addresses, and your server responds within 5 seconds, including 3 seconds to connect. Paystack doesn't follow redirects.

After you fix the issue, ping the endpoint again. You can ping up to 10 times every 60 seconds in each mode.

Pause or remove an endpoint

To stop sending events to an endpoint for a while, set enabled to false with Update Webhook EndpointAPI. The endpoint keeps its URL and events, and you set enabled back to true to resume. To remove an endpoint for good, use Delete Webhook EndpointAPI.

When you update an endpoint's events, the list you send replaces the current one. It can't be empty, so pause or delete the endpoint instead of removing all of its events.

Track deliveries to an endpoint

Paystack sends a separate copy of an event to each endpoint subscribed to it. Each copy in your event history has the webhook_url_code of the endpoint it went to. To see the events sent to one endpoint, pass its webhook_url_code to List EventsAPI. When you resend one of those events, it goes to the endpoint's current URL.

Supported events

1{
2 "event": "customeridentification.failed",
3 "data": {
4 "customer_id": 82796315,
5 "customer_code": "CUS_XXXXXXXXXXXXXXX",
6 "email": "[email protected]",
7 "identification": {
8 "country": "NG",
9 "type": "bank_account",
10 "bvn": "123*****456",
11 "account_number": "012****345",
12 "bank_code": "999991"
13 },
14 "reason": "Account number or BVN is incorrect"
15 }
16}

Types of events

Here are the events we currently raise. We would add more to this list as we hook into more actions in the future.

EventDescription
charge.dispute.createA dispute was logged against your business
charge.dispute.remindA logged dispute hasn't been resolved
charge.dispute.resolveA dispute has been resolved
charge.successA successful charge was made
customeridentification.failedA customer ID validation has failed
customeridentification.successA customer ID validation was successful
dedicatedaccount.assign.failedThis is sent when a DVA couldn't be created and assigned to a customer
dedicatedaccount.assign.successThis is sent when a DVA has been successfully created and assigned to a customer
invoice.createAn invoice has been created for a subscription on your account. This usually happens 3 days before the subscription is due or whenever we send the customer their first pending invoice notification
invoice.payment_failedA payment for an invoice failed
invoice.updateAn invoice has been updated. This usually means we were able to charge the customer successfully. You should inspect the invoice object returned and take necessary action
paymentrequest.pendingA payment request has been sent to a customer
paymentrequest.successA payment request has been paid for
refund.failedRefund can't be processed. Your account will be credited with refund amount
refund.pendingRefund initiated, waiting for response from the processor.
refund.processedRefund has successfully been processed by the processor.
refund.processingRefund has been received by the processor.
subscription.createA subscription has been created
subscription.disableA subscription on your account has been disabled
subscription.expiring_cardsContains information on all subscriptions with cards that are expiring that month. Sent at the beginning of the month, to merchants using Subscriptions
subscription.not_renewA subscription on your account's status has changed to non-renewing. This means the subscription won't be charged on the next payment date
transfer.failedA transfer you attempted has failed
transfer.successA successful transfer has been completed
transfer.reversedA transfer you attempted has been reversed