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):

  1. Add an endpoint URL.
  2. Check the events you want.
  3. 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 to customer.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 = hex HMAC-SHA256(<webhook signing secret>, <t>.<raw body>).

To verify:

  1. Parse the header.
  2. Compute the expected signature using your webhook's signing secret (set in the dashboard when you create the endpoint).
  3. Compare in constant time.
  4. 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 → → Send test event

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

Plugipay — Payments that don't tax your success