Skip to main content
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

EventWhen it fires
payment.createdA new payment intent is created
payment.completedPayment verified on Stellar (or recorded manually as completed)
payment.failedPayment failed on Stellar
payment.expiredPayment passed its expiration time
webhook.testSent 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"
  }
}

Headers

HeaderDescription
Payoes-SignatureHMAC signature (t=<unix>,v1=<hex>)
Payoes-EventEvent name (same as event in the body)
Payoes-TimestampUnix timestamp in seconds, used when signing
Payoes-Delivery-IDUnique 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:
AttemptDelay after failure
11 minute
25 minutes
330 minutes
42 hours
524 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

ProblemWhat to check
Signature verification failsRaw body was altered, or wrong whsec_... secret
No events arrivingEndpoint disabled, events not subscribed, or firewall rules
Same event delivered multiple timesHandler returned non-2xx; deduplicate on Payoes-Delivery-ID
Test events work, payments don’tPayment events not selected on the endpoint