#Webhooks
Webhooks push events to your server as things happen in your PlutoPay account — a payment succeeds, a refund lands, a payout pays out. Register one or more HTTPS endpoints and subscribe to the events you care about.
#Registering an endpoint
In the Dashboard (Developers → Webhooks) or via the API:
POST /v1/webhook-endpoints
| Field | Type | Description |
|---|---|---|
url |
string | Your HTTPS endpoint. |
events |
array | Event types to receive, or ["*"] for all. |
description |
string | Optional label. |
The response includes a signing secret (whsec_…) once — store it; you'll use it to verify signatures.
GET /v1/webhook-endpoints
GET /v1/webhook-endpoints/{id}
PUT /v1/webhook-endpoints/{id}
DELETE /v1/webhook-endpoints/{id}
POST /v1/webhook-endpoints/{id}/test # send a test delivery
#Event catalog
Every event below is really emitted — there are no placeholder events. Names use resource.past_tense_verb grammar with PlutoPay-owned resource names (they stay stable even if the underlying processor changes).
| Event | Fires when |
|---|---|
payment.created |
A payment is created (pending). |
payment.processing |
An async payment is settling (e.g. ACH, which can take days). |
payment.succeeded |
A payment completed successfully. |
payment.failed |
A payment attempt failed. |
payment.canceled |
A payment was canceled. |
refund.created |
A refund succeeded (full or partial). |
payout.paid |
A payout reached the bank. |
payout.failed |
A payout failed. |
dispute.created |
A dispute/chargeback (or late ACH return) was opened. |
dispute.closed |
A dispute resolved — payload carries the outcome (won/lost). |
terminal.connected |
A reader came online. |
terminal.disconnected |
A reader went offline. |
customer.created |
A customer was created. |
customer.updated |
A customer was updated. |
Subscribe to specific events, or to ["*"] to receive all of them (including any added later).
#Payload shape
Each delivery is a JSON POST with these headers:
| Header | Meaning |
|---|---|
X-PlutoPay-Event |
The event type, e.g. payment.succeeded. |
X-PlutoPay-Delivery |
Unique delivery/event id. |
X-PlutoPay-Signature |
t={timestamp},v1={signature} — see below. |
X-PlutoPay-Timestamp |
Unix timestamp of the delivery. |
{
"id": "payment.succeeded_1783319490",
"type": "payment.succeeded",
"created_at": "2026-07-05T14:12:03+00:00",
"data": {
"id": "019c8044-…",
"reference": "txn_…",
"amount": 4750,
"currency": "usd",
"status": "succeeded"
}
}
Always return a 2xx quickly (ideally after enqueueing the work). Non-2xx responses are retried.
#Verifying signatures
The X-PlutoPay-Signature header is t={timestamp},v1={signature}, where signature = HMAC_SHA256( secret, "{timestamp}.{raw_request_body}" ).
To verify: parse t and v1, recompute the HMAC over "{t}.{raw_body}" with your endpoint's signing secret, compare in constant time, and reject if t is older than your tolerance (recommended: 300 seconds) to prevent replay.
Important: verify against the raw request body bytes, before any JSON parsing / re-serialization — re-encoding changes the bytes and breaks the HMAC.
#Node.js
const crypto = require('crypto');
function verifyPlutoPaySignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')));
const timestamp = parts.t;
const provided = parts.v1;
if (!timestamp || !provided) return false;
// Replay protection
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(provided);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express — capture the RAW body for verification
const express = require('express');
const app = express();
app.post('/webhooks/plutopay',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
const ok = verifyPlutoPaySignature(
raw,
req.header('X-PlutoPay-Signature'),
process.env.PLUTOPAY_WEBHOOK_SECRET
);
if (!ok) return res.status(400).send('invalid signature');
const event = JSON.parse(raw);
// handle event.type … enqueue work, then:
res.sendStatus(200);
}
);
#PHP
<?php
function verifyPlutoPaySignature(
string $rawBody,
string $header,
string $secret,
int $toleranceSeconds = 300
): bool {
// Parse "t=...,v1=..."
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, null);
$parts[$k] = $v;
}
$timestamp = $parts['t'] ?? null;
$provided = $parts['v1'] ?? null;
if (!$timestamp || !$provided) {
return false;
}
// Replay protection
if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);
return hash_equals($expected, $provided);
}
// Usage — read the RAW body, do NOT json_decode before verifying
$raw = file_get_contents('php://input');
$ok = verifyPlutoPaySignature(
$raw,
$_SERVER['HTTP_X_PLUTOPAY_SIGNATURE'] ?? '',
getenv('PLUTOPAY_WEBHOOK_SECRET')
);
if (!$ok) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($raw, true);
// handle $event['type'] …
http_response_code(200);
#Retries & delivery logs
If your endpoint doesn't return 2xx, PlutoPay retries with exponential backoff:
attempt 1 → +60s → +5m → +30m → +2h → +24h
After repeated consecutive failures an endpoint is automatically disabled; re-enable it in the Dashboard once your receiver is healthy. Every attempt is recorded in the endpoint's delivery log, and you can replay a delivery from there.
Design your handler to be idempotent: the same event may be delivered more than once. De-duplicate on X-PlutoPay-Delivery (or the body's id).