Webhooks
Webhooks are how Paykore notifies your server in real time when something happens — without you having to ask.
Why webhooks?
Many PayKore operations are asynchronous: bank transfers settle over seconds to minutes, KYC verification calls out to a third-party provider, settlements run on a schedule. Rather than having your server poll an endpoint every second to check for status changes, PayKore pushes a notification to your server the moment something happens.
This is faster (you find out within seconds, not on your next poll cycle), cheaper (no wasted requests checking for nothing), and more reliable (PayKore retries delivery automatically if your server is briefly unavailable).
How PayKore delivers webhooks
PayKore sends an HTTP POST request with a JSON body to your registered URL. Your server must respond with a 2xx status code within 10 seconds.
If your endpoint doesn't respond in time, returns a non-2xx status, or is unreachable, PayKore retries with exponential backoff:
1s → 5s → 30s → 5m → 30m → 2h → 6h → 24h
After 8 failed attempts, the delivery is marked as abandoned. You can view abandoned deliveries — and manually retry them — in your dashboard under Webhooks → Deliveries.
Registering an endpoint
curl -X POST https://api.paykore.dev/v1/webhooks \
-H "X-API-Key: sk_test_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/paykore",
"events": ["transaction.completed", "transaction.failed", "kyc.verified", "kyc.failed"]
}'
Response:
{
"id": "wh_5rTuV2wXyZ",
"url": "https://yourapp.com/webhooks/paykore",
"events": ["transaction.completed", "transaction.failed", "kyc.verified", "kyc.failed"],
"secret": "whsec_a1b2c3d4e5f6a1b2c3d4e5f6",
"status": "active",
"created_at": "2025-06-01T15:00:00Z"
}
Save the secret immediately — it's shown only once and is required to verify incoming signatures (see Verifying Signatures).
You can also register and manage webhook endpoints from the dashboard under Settings → Webhooks, which shows the same fields in a form alongside a live delivery log.
Your webhook URL must use HTTPS. Plain HTTP URLs are rejected at registration with 400 INVALID_WEBHOOK_URL. This protects the payload (which may include transaction references and amounts) in transit.
Handling a webhook correctly
app.post('/webhooks/paykore', express.raw({ type: 'application/json' }), (req, res) => {
// 1. Verify signature FIRST
const signature = req.headers['x-paykore-signature'];
const isValid = verifySignature(process.env.PAYKORE_WEBHOOK_SECRET, req.body, signature);
if (!isValid) return res.status(401).send('Invalid signature');
// 2. Return 200 immediately
res.status(200).send('OK');
// 3. Process the event asynchronously
const event = JSON.parse(req.body);
processEvent(event).catch(console.error);
});
Each step matters, in this order:
- Verify the signature before doing anything else. An unverified payload could be forged by anyone who discovers your URL.
- Respond
200immediately, before running your business logic. PayKore is measuring the time to your HTTP response, not the time to finish processing. - Process the event after responding. Do the actual database writes, emails, or order updates asynchronously.
If your processing logic takes longer than 10 seconds and you respond only after it finishes, PayKore will treat the delivery as timed out and retry — even though you eventually did receive and act on the event. Always respond first, process second.
Retry behaviour and idempotency
Because PayKore retries on any non-2xx or timeout, your webhook handler may receive the same event more than once — for example, if your server returned 200 but the response was lost in transit before PayKore registered it. Your handler must be idempotent: processing the same event twice should have no additional effect.
Use event.data.id (the transaction, KYC, or settlement ID) as a deduplication key:
async function processEvent(event) {
const eventId = event.data.id;
// Check if we've already processed this event
const alreadyProcessed = await redis.get(`webhook:processed:${eventId}`);
if (alreadyProcessed) {
return; // Skip — already handled
}
// Do the actual work
await updateOrderStatus(event);
// Mark as processed (expire after 7 days)
await redis.set(`webhook:processed:${eventId}`, '1', 'EX', 604800);
}
A relational processed_events table works just as well if you don't have Redis available:
CREATE TABLE processed_events (
event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT NOW()
);
Insert the event ID before processing; if the insert fails on the primary key constraint, you've already handled it.
Viewing delivery history
Your dashboard, under Webhooks → Deliveries, shows every delivery attempt PayKore has made: the event type, the HTTP status code your server returned, the response time, and the retry number. This is the first place to look when debugging a webhook that isn't arriving — it tells you definitively whether PayKore sent it and what your server said back.
Testing webhooks locally
Your local development server isn't reachable from the internet, so PayKore can't deliver webhooks directly to localhost. Use a tunnelling tool like ngrok to expose your local server temporarily:
ngrok http 3000
This prints a public HTTPS URL like https://a1b2-c3d4.ngrok-free.app. Register that URL (with your webhook path appended) as your sandbox webhook endpoint:
curl -X POST https://api.paykore.ng/v1/webhooks \
-H "X-API-Key: sk_test_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"url": "https://a1b2-c3d4.ngrok-free.app/webhooks/paykore",
"events": ["transaction.completed"]
}'
Trigger a test transaction in sandbox, and you'll see the webhook hit your local server through the tunnel, with full request/response logging available in the ngrok web inspector at http://127.0.0.1:4040.
Next steps
- Verifying Signatures → — Copy-paste signature verification code in multiple languages.
- Event Reference → — Full catalog of every webhook event and payload.