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
- 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. - Parse
Pehmen-Signature:t(unix seconds),kid,v1(base64). - Pick the key whose
kidmatches. An unknownkidfails; do not try other keys. - The signed message is the bytes of
t + "." + raw_body. Verifyv1over them with Ed25519. - Reject if
|now − t|exceeds 300 seconds. - Answer
2xxwithin 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
| Event | When |
|---|---|
payment.intent.created.v1 | An intent was recorded. |
payment.intent.succeeded.v1 | Authorized or captured. |
payment.intent.failed.v1 | Declined, no route, or technical failure. |
payment.intent.cancelled.v1 | Cancelled. |
payment.attempt.requires_action.v1 | The customer must act (3DS, QR). |
refund.updated.v1 | A refund changed state. |
vce.published.v1 | An envelope was signed. |
beneficiary.change.requested.v1 / .approved.v1 / .applied.v1 | The change workflow. |
payout.instruction.held.v1 / .released.v1 / .succeeded.v1 / .failed.v1 | The payout lifecycle. |
case.opened.v1 / case.resolved.v1 / appeal.decided.v1 | Cases. |
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.