Webhooks
Plugipay sends webhook events to URLs you control so you can react to state changes in real time — payments succeeding, refunds completing, subscriptions renewing.
This page is the API reference for webhooks: every event type, the signature scheme, the retry policy, the payload shape. For the portal walkthrough (how to add an endpoint in the dashboard), see Portal → Webhooks.
Event payload shape
Every webhook is a POST to your endpoint with this body:
{
"id": "evt_01HXxxxxxxxxxxxxxxxxxxxxxx",
"type": "payment.succeeded",
"createdAt": "2026-05-12T10:42:00.123Z",
"workspaceId": "ws_01HYxxxxxxxxxxxxxxxxxxxxxx",
"data": {
/* the resource that triggered the event */
},
"previousAttributes": {
/* only on .updated events: the diff */
}
}
| Field | Notes |
|---|---|
id |
Unique event ID. Use for deduplication. |
type |
<resource>.<action> — see the catalog below. |
createdAt |
When the event fired, not when you received it. |
workspaceId |
The workspace that produced the event. |
data |
The full resource object (same shape as the API returns). |
previousAttributes |
Diff for .updated events; absent otherwise. |
Event catalog
Events follow the pattern <resource>.<action>. The current full list:
Payments
| Event | When |
|---|---|
payment.succeeded |
Money confirmed received |
payment.failed |
Provider declined or errored |
payment.updated |
Status or metadata changed |
payment.captured |
Auth → capture flow completed (cards only) |
Refunds
| Event | When |
|---|---|
refund.created |
Refund initiated |
refund.succeeded |
Refund confirmed by provider |
refund.failed |
Provider rejected the refund |
refund.updated |
Status or metadata changed |
Checkout sessions
| Event | When |
|---|---|
checkout_session.completed |
Customer finished checkout successfully |
checkout_session.expired |
TTL elapsed without payment |
checkout_session.async_payment_succeeded |
Async method (bank transfer, etc.) confirmed |
checkout_session.async_payment_failed |
Async method confirmed-failed |
Customers
| Event | When |
|---|---|
customer.created |
New customer record |
customer.updated |
Email, name, metadata, or payment method changed |
customer.deleted |
Hard delete (rare; usually archive instead) |
Subscriptions
| Event | When |
|---|---|
subscription.created |
New subscription |
subscription.updated |
Plan change, pause, schedule update |
subscription.deleted |
Canceled (cancellation is a delete in our model) |
subscription.trial_will_end |
3 days before trial ends |
subscription.past_due |
Payment failed, entered dunning |
Invoices
| Event | When |
|---|---|
invoice.created |
Generated (typically by subscription billing) |
invoice.finalized |
Locked, ready to attempt payment |
invoice.paid |
Successfully paid |
invoice.payment_failed |
Payment attempt failed |
invoice.void |
Manually voided |
Plans
| Event | When |
|---|---|
plan.created |
|
plan.updated |
|
plan.archived |
Payouts
| Event | When |
|---|---|
payout.initiated |
We've kicked off the transfer |
payout.paid |
Funds arrived in your bank account |
payout.failed |
Bank rejected (wrong account, etc.) |
Webhook endpoints (meta)
| Event | When |
|---|---|
webhook_endpoint.disabled |
We disabled it after too many failures |
Subscribing to events
In Settings → Webhooks (or via API: webhook endpoints):
- Add an endpoint URL.
- Check the events you want.
- Save.
We deliver only the events you've subscribed to. Adding an event later affects only future deliveries — you don't get a retroactive backfill.
If you want all events, leave the subscription list empty (we treat empty as "everything").
Subscribe narrowly. Each event delivery costs us a tiny amount of compute and you a tiny amount of bandwidth. If you only care about
payment.succeeded, don't subscribe tocustomer.updated— the volume is much higher.
Signing
Every webhook is signed so you can verify it came from Plugipay (not a malicious party who knows your URL).
Headers we send:
Content-Type: application/json
Plugipay-Signature: t=1715526783, v1=7c4f1a2d3b4c5d...
Plugipay-Event-Id: evt_01H...
Plugipay-Webhook-Id: webhep_01H...
The signature format is t=<timestamp>, v1=<sha256-hmac>:
t= epoch seconds when the event was signed.v1= hexHMAC-SHA256(<webhook signing secret>, <t>.<raw body>).
To verify:
- Parse the header.
- Compute the expected signature using your webhook's signing secret (set in the dashboard when you create the endpoint).
- Compare in constant time.
- Reject if the timestamp is more than 5 minutes old (replay protection).
Verification in each language
Node.js:
import crypto from 'node:crypto';
import { PlugipaySignatureError } from '@forjio/plugipay-node/webhooks';
function verifyWebhook(body, signature, secret, toleranceSec = 300) {
const [tPart, v1Part] = signature.split(', ');
const t = parseInt(tPart.replace('t=', ''), 10);
const v1 = v1Part.replace('v1=', '');
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) {
throw new PlugipaySignatureError('timestamp_skew');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${body}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
throw new PlugipaySignatureError('signature_mismatch');
}
}
Python:
import hmac, hashlib, time
def verify_webhook(body: bytes, signature: str, secret: str, tolerance: int = 300):
parts = dict(p.strip().split('=', 1) for p in signature.split(','))
t = int(parts['t'])
v1 = parts['v1']
if abs(int(time.time()) - t) > tolerance:
raise ValueError('timestamp_skew')
expected = hmac.new(
secret.encode(),
f'{t}.'.encode() + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, v1):
raise ValueError('signature_mismatch')
Go:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func VerifyWebhook(body []byte, signature, secret string, tolerance time.Duration) error {
parts := map[string]string{}
for _, p := range strings.Split(signature, ", ") {
kv := strings.SplitN(p, "=", 2)
parts[kv[0]] = kv[1]
}
t, _ := strconv.ParseInt(parts["t"], 10, 64)
v1 := parts["v1"]
if time.Since(time.Unix(t, 0)).Abs() > tolerance {
return fmt.Errorf("timestamp_skew")
}
payload := fmt.Sprintf("%d.%s", t, body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(v1)) {
return fmt.Errorf("signature_mismatch")
}
return nil
}
Each SDK exports a verifyWebhook helper that does all this for you. See SDK → webhooks.
What to return
After processing successfully:
HTTP/1.1 200 OK
Return within 10 seconds, or we time out and retry. For long-running work (sending emails, running ML, etc.), queue it and respond immediately.
For unrecoverable rejections (signature failed, business-rule reject):
HTTP/1.1 4xx <reason>
We don't retry on 4xx. We mark the delivery as permanently rejected.
For recoverable errors (your service is overloaded, DB connection failure):
HTTP/1.1 5xx <reason>
We retry on 5xx per the schedule below.
Retry policy
Failed deliveries (timeout, 5xx, network error) are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 (first delivery) | Immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| 8 | 48 hours |
| Final | After 72 hours total, marked permanently failed |
You can manually retry from the dashboard at any point during the 72-hour window. After permanent failure, the event is in your delivery log but won't fire again.
If an endpoint has 20 consecutive failures, we automatically disable it and fire webhook_endpoint.disabled. Re-enable manually in the dashboard.
Order guarantees
Events fire in roughly the order they happened, but not strictly. A payment.succeeded and an immediately-following refund.created can arrive in either order.
Don't rely on order for state machines. Use the data payload (which is the current state at event time) plus your own deduplication on id to be safe.
At-least-once delivery
Webhooks are delivered at-least-once. The same event ID can arrive twice if we retried.
Always deduplicate on event.id. A common pattern:
app.post('/webhooks', async (req, res) => {
const event = verifyWebhook(req.body, req.headers['plugipay-signature'], SECRET);
const seen = await db.events.exists({ id: event.id });
if (seen) return res.status(200).end(); // duplicate
await db.events.insert({ id: event.id, type: event.type, receivedAt: Date.now() });
await processEvent(event);
res.status(200).end();
});
We don't deduplicate on our side beyond reasonable effort — in network partitions, occasional duplicates slip through.
Replay attacks
The signature includes a timestamp; we recommend rejecting events older than 5 minutes. Otherwise an attacker who once captures a valid event can replay it indefinitely.
Most signature-verify helpers (including ours) check timestamps automatically.
Test events
Send a test event from the dashboard:
Settings → Webhooks →
You'll get the chance to pick the event type. The body is a sample payload (not a real resource), but the signature is real — verification works the same as production.
Local development
To receive production webhooks on your localhost dev server:
plugipay webhooks listen --forward-to http://localhost:3000/webhooks
This opens a long-lived WebSocket to Plugipay and forwards every event to your local endpoint. No ngrok, no public URL needed. See CLI → commands.
Common pitfalls
- Verifying the signature against the parsed JSON body. Use the raw bytes — JSON parse changes the byte representation. In Express, use
express.raw({ type: 'application/json' }). - Returning slow responses. The 10-second timeout is strict. Queue heavy work.
- Subscribing to too many event types and getting overwhelmed. Subscribe narrowly.
- Trusting unverified payloads. Always verify before acting.
- Not handling duplicates. Dedupe on
event.id.
Next
- Portal → Webhooks — managing endpoints in the dashboard.
- Resources → Webhook endpoints — the API endpoints for managing webhook endpoints programmatically.
- Resources → Events — querying event history.