PlutoPay Docs
API Reference Dashboard ↗

#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).