Webhooks
apay pushes signed JSON events to your server for every payment, refund and dispute state change.
Configure
Set the URL in Dashboard → Developers → Webhooks, or on the business profile through the API:
curl -X POST https://apay.to/api/account/$MERCHANT_ID/business_profile/$PROFILE_ID \
-H "api-key: $APAY_SECRET_KEY" -H "Content-Type: application/json" \
-d '{ "webhook_details": { "webhook_url": "https://shop.example/apay/webhook",
"webhook_version": "1.0.1",
"payment_created_enabled": true, "payment_succeeded_enabled": true, "payment_failed_enabled": true } }'Events
event_type |
When |
|---|---|
payment_succeeded |
Funds captured |
payment_failed |
Authorisation declined or errored |
payment_processing |
Asynchronous method pending (bank transfer, some wallets) |
payment_authorized |
Manual capture: authorised, awaiting capture |
payment_captured |
Manual capture completed |
payment_cancelled |
Voided before capture |
action_required |
Customer action needed (3DS, redirect) |
refund_succeeded, refund_failed |
Refund outcome |
dispute_opened, dispute_challenged, dispute_won, dispute_lost |
Dispute lifecycle |
mandate_active, mandate_revoked |
Saved payment method mandates |
payout_success, payout_failed, … |
Payouts (when enabled) |
Payload shape:
{
"merchant_id": "merchant_1788365010",
"event_id": "evt_…",
"event_type": "payment_succeeded",
"content": { "type": "payment_details", "object": { "payment_id": "pay_…", "status": "succeeded",
"amount": 1999, "currency": "USD", "connector": "stripe_test", "…": "…" } },
"timestamp": "2026-09-02T16:06:43.123Z"
}Verify the signature
Every delivery carries x-webhook-signature-512: the hex HMAC-SHA512 of the raw request body, keyed with your merchant’s payment response hash key (Dashboard → Developers → Webhooks, or payment_response_hash_key on GET /accounts/{merchant_id}). Verify before acting.
Node.js:
const crypto = require("crypto");
app.post("/apay/webhook", express.raw({ type: "*/*" }), (req, res) => {
const expected = crypto.createHmac("sha512", process.env.APAY_WEBHOOK_KEY).update(req.body).digest("hex");
const given = req.get("x-webhook-signature-512") || "";
if (expected.length !== given.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given)))
return res.status(400).end();
const event = JSON.parse(req.body);
// idempotent: store event_id, then update your order by event.content.object.payment_id
res.status(200).end();
});Python:
import hmac, hashlib
def verify(raw_body: bytes, header: str, key: str) -> bool:
expected = hmac.new(key.encode(), raw_body, hashlib.sha512).hexdigest()
return hmac.compare_digest(expected, header)Delivery
- Respond with any 2xx within 10 seconds. Anything else is treated as a failure.
- Retries: failed deliveries are retried with backoff for up to 24 hours when the scheduler is enabled for your account; in the sandbox, use Dashboard → Developers → Webhooks → Retry or
POST /webhooks/{merchant_id}/{event_id}/retry. - Order is not guaranteed; use
event_idfor idempotency andpayment_idplusstatusfor state.
Sample receiver
The demo at apay.to/checkout uses a 120-line Node.js merchant backend that creates payments and receives webhooks: see app/merchant-demo/server.js in the apay repository, or ask dev@apay.to for a copy.
Questions: dev@apay.to · Back to docs