Payaider Docs Non-custodial crypto payments API 2026-08-27

Webhooks

Signed, retried delivery you can bet a business on — and the signature-verification code to paste into your server, in three languages.

The envelope

json
{
  "id": "evt_2Ay0Zt7Rm9Kq",
  "type": "payment.confirmed",
  "created": 1787825400,
  "api_version": "2026-08-27",
  "data": {
    "id": "pi_7Fk2Qd9RmTs4Vb",
    "object": "payment_intent",
    "status": "confirmed",
    "amount": "10000",
    "currency": "USD",
    "received_amount": "100043100",
    "metadata": { "order_id": "1234" }
  }
}
  • id is your idempotency handle. Delivery is at-least-once and unordered. Dedupe on it before doing any work; every retry of an event re-sends the same id.
  • created is unix seconds, not milliseconds.
  • api_version is pinned per endpoint at creation, so adding a field to the payload can never break your parser.
  • Amounts are strings. Parse them with a big-integer or decimal type.

Verify the signature

Every delivery carries a header of the form:

http
Payaider-Signature: t=1787824800,v1=5b2a9f6c1d7e4038ab5c9d2e1f0a3b4c...

where v1 = HMAC_SHA256(endpoint_secret, "<t>.<raw_body>") — Stripe’s exact scheme, so the code you may already have works unchanged.

  • Verify against the raw body. JSON.stringify(JSON.parse(body)) changes the bytes and every check fails.
  • Compare in constant timetimingSafeEqual, hash_equals, compare_digest — never ==.
  • Reject a timestamp more than 5 minutes old or ahead. Never set the tolerance to 0.
  • Ignore any scheme that is not v1, so a future v2 cannot be downgraded past you.
  • Accept a list of secrets. Rolling a secret gives you a 24-hour dual-secret overlap; one v1 element is emitted per active secret.
  • A retry gets a fresh signature with a fresh timestamp.

Node.js

javascript
const crypto = require('node:crypto');

// Express: mount the route with express.raw({ type: 'application/json' }) so that
// req.body is the RAW bytes. JSON.stringify(JSON.parse(body)) changes the bytes and
// every signature check against it fails.
function verifyPayaiderSignature(header, rawBody, secrets, toleranceSeconds) {
  const tolerance = toleranceSeconds === undefined ? 300 : toleranceSeconds;
  let timestamp = null;
  const signatures = [];

  for (const element of String(header || '').split(',')) {
    const separator = element.indexOf('=');
    if (separator === -1) continue;
    const key = element.slice(0, separator).trim();
    const value = element.slice(separator + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value); // every other scheme is ignored
  }

  if (timestamp === null || !/^[0-9]+$/.test(timestamp) || signatures.length === 0) return false;

  // Replay protection: reject a signature that is more than 5 minutes old or ahead.
  const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
  if (age > tolerance) return false;

  const signedPayload = Buffer.concat([
    Buffer.from(timestamp + '.', 'utf8'),
    Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'),
  ]);

  let valid = false;
  for (const secret of secrets) {
    const expected = Buffer.from(
      crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'),
      'utf8',
    );
    for (const candidate of signatures) {
      const received = Buffer.from(candidate, 'utf8');
      // Constant time, and no early return: the work must not reveal which one matched.
      if (expected.length === received.length && crypto.timingSafeEqual(expected, received)) {
        valid = true;
      }
    }
  }
  return valid;
}

// app.post('/webhooks/payaider', express.raw({ type: 'application/json' }), (req, res) => {
//   if (!verifyPayaiderSignature(req.get('Payaider-Signature'), req.body, [process.env.PAYAIDER_WEBHOOK_SECRET])) {
//     return res.status(400).send('bad signature');
//   }
//   const event = JSON.parse(req.body.toString('utf8'));
//   // Delivery is at-least-once and unordered: dedupe on event.id before doing any work,
//   // and fulfil on 'payment.confirmed' only — never on 'payment.detected'.
//   if (alreadyProcessed(event.id)) return res.sendStatus(200);
//   if (event.type === 'payment.confirmed') fulfillOrder(event.data.metadata.order_id);
//   res.sendStatus(200); // ACK fast, then work asynchronously: the timeout is 10s.
// });

PHP

php
<?php
// $raw_body = file_get_contents('php://input');  // RAW body — never re-encode the JSON.
function payaider_verify_signature(string $header, string $raw_body, array $secrets, int $tolerance = 300): bool
{
    $timestamp = null;
    $signatures = [];

    foreach (explode(',', $header) as $element) {
        $pair = explode('=', trim($element), 2);
        if (count($pair) !== 2) {
            continue;
        }
        if ($pair[0] === 't') {
            $timestamp = $pair[1];
        } elseif ($pair[0] === 'v1') {
            $signatures[] = $pair[1]; // every other scheme is ignored
        }
    }

    if ($timestamp === null || !ctype_digit($timestamp) || count($signatures) === 0) {
        return false;
    }

    // Replay protection: 5-minute tolerance, in both directions.
    if (abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }

    $signed_payload = $timestamp . '.' . $raw_body;
    $valid = false;

    foreach ($secrets as $secret) {
        $expected = hash_hmac('sha256', $signed_payload, $secret);
        foreach ($signatures as $candidate) {
            // hash_equals is constant time. No early break: keep the work uniform.
            if (hash_equals($expected, $candidate)) {
                $valid = true;
            }
        }
    }

    return $valid;
}

// $ok = payaider_verify_signature(
//     $_SERVER['HTTP_PAYAIDER_SIGNATURE'] ?? '',
//     file_get_contents('php://input'),
//     [getenv('PAYAIDER_WEBHOOK_SECRET')]
// );
// if (!$ok) { http_response_code(400); exit('bad signature'); }
// $event = json_decode(file_get_contents('php://input'), true);
// Delivery is at-least-once and unordered: dedupe on $event['id'] before doing any work,
// and fulfil on 'payment.confirmed' only — never on 'payment.detected'.

Python

python
import hashlib
import hmac
import time


def verify_payaider_signature(header: str, raw_body: bytes, secrets: list[str], tolerance: int = 300) -> bool:
    timestamp = None
    signatures = []

    for element in (header or "").split(","):
        key, separator, value = element.strip().partition("=")
        if not separator:
            continue
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)  # every other scheme is ignored

    if timestamp is None or not timestamp.isdigit() or not signatures:
        return False

    # Replay protection: 5-minute tolerance, in both directions.
    if abs(int(time.time()) - int(timestamp)) > tolerance:
        return False

    signed_payload = timestamp.encode("utf-8") + b"." + raw_body
    valid = False

    for secret in secrets:
        expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
        for candidate in signatures:
            # compare_digest is constant time. No early return: keep the work uniform.
            if hmac.compare_digest(expected, candidate):
                valid = True

    return valid


# Flask / FastAPI: read the RAW body (request.get_data() / await request.body()) —
# never json.dumps(json.loads(body)), which changes the bytes and breaks the signature.
#
#   if not verify_payaider_signature(request.headers.get("Payaider-Signature", ""),
#                                   request.get_data(), [os.environ["PAYAIDER_WEBHOOK_SECRET"]]):
#       return "bad signature", 400
#   event = request.get_json()
#   # Delivery is at-least-once and unordered: dedupe on event["id"] before doing any work,
#   # and fulfil on "payment.confirmed" only — never on "payment.detected".

Every event

EventWhat to do
payment.createdInformational or review-queue.
payment.detectedDo not ship. 0-conf, can still be reorged out.
payment.confirmingInformational or review-queue.
payment.confirmedFulfil here.
payment.underpaidInformational or review-queue.
payment.overpaidFulfil here.
payment.paid_lateInformational or review-queue.
payment.expiredInformational or review-queue.
payment.canceledInformational or review-queue.
payment.failedInformational or review-queue.

Reserved for Phase 2 and unable to fire today: refund.created, refund.succeeded, refund.failed. They exist in the catalogue so their names are stable — do not build against them yet.

Retries

Only a 2xx counts as delivered. A redirect is a failure, as are 4xx, 5xx, TLS errors and timeouts. Failed deliveries are retried up to 8 attempts over about three days:

AttemptScheduledElapsed
1immediately0
2+1 minute after the previous attempt< 1h
3+5 minutes after the previous attempt< 1h
4+30 minutes after the previous attempt~1h
5+2 hours after the previous attempt~3h
6+6 hours after the previous attempt~9h
7+12 hours after the previous attempt~21h
8+24 hours after the previous attempt~45h
  • Respond within 10 seconds. ACK first and do your work asynchronously.
  • After sustained failure the endpoint is disabled and you are emailed. The events stay queryable.
  • Manual redelivery never cancels the automatic schedule, so both can arrive — which is fine, because you dedupe on event.id.
  • Ordering is not guaranteed and duplicates can occur. Re-read the payment over REST as your recovery path.

The delivery log

bash
curl "https://api.payaider.com/v1/webhook_endpoints/we_6Bd4Lq2Xs7Hf/deliveries?limit=25" \
  -H "Authorization: Bearer sk_test_YOUR_KEY"

Every attempt, with the status code, latency, and a truncated copy of your own response — which is usually enough to see the stack trace your handler threw.

Egress rules you should know about

  • DNS is resolved on every attempt and private IP ranges are blocked, so a rebinding attack cannot turn your webhook URL into a probe of our network.
  • Redirects are never followed. Point us at the final URL.
  • The connection timeout is 10 seconds.
  • In live mode the URL must be https. Test mode allows http so a tunnel can reach your laptop.