Webhooks push payment events to your server over HTTPS. Each request is signed with HMAC-SHA256 so you can confirm it came from Payoes.
Events
| Event | When it fires |
|---|
payment.created | A new payment intent is created |
payment.completed | Payment verified on Stellar (or recorded manually as completed) |
payment.failed | Payment failed on Stellar |
payment.expired | Payment passed its expiration time |
webhook.test | Sent when you click Send test event in the dashboard |
Pick the events you need per endpoint under Developers → Webhooks.
Create an endpoint
In Developers → Webhooks, add:
- URL — your HTTPS endpoint (e.g.
https://api.example.com/webhooks/payoes)
- Events — which events to receive
- Signing secret — generated on creation (
whsec_...)
Copy the signing secret when it is shown. After that you only get a masked preview. Use Rotate secret to issue a new one.
Payload
Payoes sends a POST with a JSON body:
{
"event": "payment.completed",
"data": {
"id": "pay_nLU2gUDk-v3tdyd6",
"object": "payment_intent",
"amount": "2.0000000",
"pricing_currency": "USD",
"pricing_amount": "10.00",
"status": "completed",
"description": "Invoice #1024",
"metadata": { "order_id": "ord_123" },
"checkout_url": "https://payoes.example.com/c/pay_nLU2gUDk-v3tdyd6",
"tx_hash": "0658f6099741d7a783ec5d87389f782ab5f207a13c0ddbe8cbe24d9c19834c7e",
"confirmed_at": "2026-07-07T06:07:00.000Z",
"expires_at": "2026-07-07T07:00:00.000Z",
"created_at": "2026-07-07T06:00:00.000Z"
}
}
| Header | Description |
|---|
Payoes-Signature | HMAC signature (t=<unix>,v1=<hex>) |
Payoes-Event | Event name (same as event in the body) |
Payoes-Timestamp | Unix timestamp in seconds, used when signing |
Payoes-Delivery-ID | Unique delivery ID — use for idempotency |
POST /webhooks/payoes HTTP/1.1
Content-Type: application/json
Payoes-Signature: t=1719820800,v1=8f2b...
Payoes-Event: payment.completed
Payoes-Timestamp: 1719820800
Payoes-Delivery-ID: 6f1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a
Verify the signature
Payoes signs {timestamp}.{raw_body} with your webhook secret. The digest appears in Payoes-Signature as v1=<hex>.
Verify the signature before you trust the payload. Use the raw request body as received — parsing and re-serializing JSON will break verification.
Reject timestamps more than five minutes old.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
const WEBHOOK_TOLERANCE_SECONDS = 300;
function extractSignatureDigest(signatureHeader: string) {
const match = signatureHeader.match(/v1=([a-f0-9]+)/i);
return match?.[1] ?? null;
}
export function verifyPayoesWebhook(input: {
secret: string;
rawBody: string;
signatureHeader: string;
timestampHeader: string;
}) {
const timestamp = Number(input.timestampHeader);
if (!Number.isFinite(timestamp)) {
return false;
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
return false;
}
const providedDigest = extractSignatureDigest(input.signatureHeader);
if (!providedDigest) {
return false;
}
const expected = createHmac("sha256", input.secret)
.update(`${timestamp}.${input.rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(providedDigest, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
Next.js App Router
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("payoes-signature") ?? "";
const timestamp = request.headers.get("payoes-timestamp") ?? "";
const deliveryId = request.headers.get("payoes-delivery-id") ?? "";
const event = request.headers.get("payoes-event") ?? "";
const valid = verifyPayoesWebhook({
secret: process.env.PAYOES_WEBHOOK_SECRET!,
rawBody,
signatureHeader: signature,
timestampHeader: timestamp,
});
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const payload = JSON.parse(rawBody) as {
event: string;
data: Record<string, unknown>;
};
// Store deliveryId after processing to skip duplicates on retry.
console.log({ deliveryId, event, data: payload.data });
return NextResponse.json({ received: true });
}
Python (Flask)
import hmac
import hashlib
import re
import time
TOLERANCE_SECONDS = 300
def verify_payoes_webhook(secret: str, raw_body: bytes, signature: str, timestamp: str) -> bool:
try:
ts = int(timestamp)
except ValueError:
return False
if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
return False
match = re.search(r"v1=([a-f0-9]+)", signature, re.I)
if not match:
return False
signed_payload = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, match.group(1))
Test events
Click Send test event on a webhook in the dashboard. Payoes delivers a webhook.test payload with sample payment data (metadata.payoes_test = "true"). It shows up in delivery logs the same way as live events.
Retries
If your endpoint returns a non-2xx status or times out, Payoes retries up to five times:
| Attempt | Delay after failure |
|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 24 hours |
A failed delivery stays pending until the next attempt. After the fifth failure it moves to failed. You can also hit Retry in the dashboard delivery log.
Return 2xx as soon as you’ve accepted the event. Queue any slow work — Payoes will retry if your handler is slow or errors.
Delivery logs
On the webhook detail page you can inspect each delivery:
- Delivery ID (
Payoes-Delivery-ID)
- Event, status, and HTTP response code
- Attempt count and next retry time
- Request payload, response body, and last error
Troubleshooting
| Problem | What to check |
|---|
| Signature verification fails | Raw body was altered, or wrong whsec_... secret |
| No events arriving | Endpoint disabled, events not subscribed, or firewall rules |
| Same event delivered multiple times | Handler returned non-2xx; deduplicate on Payoes-Delivery-ID |
| Test events work, payments don’t | Payment events not selected on the endpoint |