Skip to main content

Outbound webhooks

Pehmen signs every delivery with a platform Ed25519 key. There is no shared secret to store, rotate or leak. You verify against the public key.

The request​

POST https://your-endpoint
Content-Type: application/json
User-Agent: Pehmen-Webhooks/1.0
Pehmen-Event-Id: evt_…
Pehmen-Signature: t=1788750000,kid=1,v1=<base64 signature>
{
"id": "evt_…",
"type": "payment.intent.succeeded.v1",
"created_at": "2026-09-06T12:34:56.000Z",
"tenant_id": "org_…",
"data": { … }
}

The body is serialized once when the event is fanned out and frozen; the signature covers those exact bytes.

Verifying​

  1. Fetch GET https://pehmen-api.huper.technology/.well-known/pehmen-webhook-keys.json. It lists { kid, alg: "EdDSA", public_key } (raw 32-byte key, base64). Cache it for five minutes.
  2. Parse Pehmen-Signature: t (unix seconds), kid, v1 (base64).
  3. Pick the key whose kid matches. An unknown kid fails; do not try other keys.
  4. The signed message is the bytes of t + "." + raw_body. Verify v1 over them with Ed25519.
  5. Reject if |now − t| exceeds 300 seconds.
  6. Answer 2xx within 10 seconds. Anything else is a failure and will be retried.
import { createPublicKey, verify } from 'node:crypto';

export function verifyPehmen(rawBody, signatureHeader, keys) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const key = keys.find((k) => String(k.kid) === parts.kid);
if (!key) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const pub = createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(key.public_key, 'base64')]), format: 'der', type: 'spki' });
return verify(null, Buffer.concat([Buffer.from(parts.t + '.'), rawBody]), pub, Buffer.from(parts.v1, 'base64'));
}

Use the raw request body, not a re-serialized one.

Events​

EventWhen
payment.intent.created.v1An intent was recorded.
payment.intent.succeeded.v1Authorized or captured.
payment.intent.failed.v1Declined, no route, or technical failure.
payment.intent.cancelled.v1Cancelled.
payment.attempt.requires_action.v1The customer must act (3DS, QR).
refund.updated.v1A refund changed state.
vce.published.v1An envelope was signed.
beneficiary.change.requested.v1 / .approved.v1 / .applied.v1The change workflow.
payout.instruction.held.v1 / .released.v1 / .succeeded.v1 / .failed.v1The payout lifecycle.
case.opened.v1 / case.resolved.v1 / appeal.decided.v1Cases.

Delivery and retries​

  • One delivery at a time per endpoint, oldest first, so order holds per endpoint.
  • Timeout 10 seconds. Redirects are never followed. Only your status code is kept.
  • Backoff: 30 s doubling to a cap of one hour, multiplied by a random 0.5 to 1.5. Up to 20 attempts.
  • After 20 failures the delivery and the endpoint are DISABLED. Fix the endpoint, enable it, and Replay from the dashboard.
  • Dedup: the same event is never delivered twice to the same endpoint, except by an explicit replay.
  • Your endpoint must be HTTPS on port 443 and publicly routable; DNS is re-resolved and re-checked at every attempt.

Idempotency on your side​

Key your handling on Pehmen-Event-Id (or the body's id). A replay carries the same id and the same bytes.