All articles
August 19, 2026 · 8 min read

Webhooks explained: why the shop pulls the real order status

A webhook tells your shop something happened. It is not proof that it happened. Here is how Paynet signs webhooks, why you should treat one as a nudge and read the payment back, and how to verify the signature in PHP and Node.

Your buyer pays on the checkout page. Their phone loses signal on the way back to your site. They never reach your thank you page. Did the order get paid?

This is the problem webhooks exist to solve, and it is also the problem webhooks are routinely trusted too far to solve. This post is about both halves: what a Paynet webhook is, and why the shop should still ask.

What a webhook is

A webhook is an HTTP request that Paynet sends to a URL of yours when something happens. Instead of your server asking "is it paid yet" every few seconds, Paynet knocks on your door once the answer is known.

Paynet sends one on every final status of a payment, which includes the unhappy ones. A payment that failed and a payment that expired unpaid both produce a transaction.status.updated event, not just the successful ones. Refunds produce a payment.refunded event.

The request goes to the callback_url you set on that payment. If you did not set one, it goes to the webhook URL configured for the domain.

The body contains a delivery_id, the event name, a livemode flag, a timestamp, a transaction object with the uuid, your order_id, the status, amount, refunded amount, currency, processor and timestamps, plus a refund object on refund events and a receipt object with the receipt id and QR URL when a fiscal receipt was issued.

Why the shop pulls the real status

Here is the rule Paynet is built around: treat the webhook as a nudge, and read the truth back before you mark an order paid.

There are three reasons.

Anyone can post to your URL. Your webhook endpoint is a public URL on the internet. Without verification, a stranger who guesses it can tell your shop that order 1042 is paid. The signature below closes that hole, but pulling the status closes it a second time, because the answer then comes from an authenticated request that you initiated.

A webhook is a snapshot, not the present. Deliveries can arrive late, out of order, or twice. If a payment moved on since the event was queued, the body you are holding is stale.

Statuses are not final in the way you expect. A late confirmation from a provider can move a payment from failed or expired to completed. Always act on the latest state you read, not the first webhook you saw.

So the flow is: verify the signature, respond 200 quickly, dedupe on delivery_id, then call GET /api/v1/orders/{order_id}/payment with your API key and act on what that returns. That endpoint is scoped to your account, so a store can only ever read its own orders, and it returns the most decisive payment for the order: a final record wins over a stale pending one.

This is exactly what the official Paynet CMS plugins do. The webhook nudges the store, the store pulls the truth. It is why a plugin keeps working correctly even when a delivery is lost.

The signature

Every delivery carries an X-Paynet-Signature header. It is the lowercase hex HMAC-SHA256 of the raw request body, computed with your webhook secret. You find that secret in the dashboard under the domain's webhook settings.

Three details decide whether your verification is actually correct.

Use the raw bytes. Compute the HMAC over the body exactly as it arrived. If your framework parses the JSON and you re-encode it, whitespace and key order can change and the signature will not match. In Express that means express.raw, not express.json.

Compare in constant time. Use hash_equals in PHP or crypto.timingSafeEqual in Node, not ==. A plain comparison leaks how much of the signature you got right.

Accept the rotation header. When you rotate your signing secret, deliveries carry both signatures for 24 hours: X-Paynet-Signature under the current secret and X-Paynet-Signature-Next under the new one. Verifying against either lets you rotate without dropping a single event.

PHP

// PHP: raw body, hex HMAC, constant-time compare, then read the truth back.
$raw    = file_get_contents('php://input');
$secret = getenv('PAYNET_WEBHOOK_SECRET');
$given  = $_SERVER['HTTP_X_PAYNET_SIGNATURE'] ?? '';
$next   = $_SERVER['HTTP_X_PAYNET_SIGNATURE_NEXT'] ?? '';
$mine   = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($mine, $given) && !hash_equals($mine, $next)) { http_response_code(401); exit; }
$event = json_decode($raw, true);
// dedupe on $event['delivery_id'], then:
$payment = json_decode(file_get_contents(
    'https://paynet.am/api/v1/orders/' . rawurlencode($event['transaction']['order_id']) . '/payment',
    false, stream_context_create(['http' => ['header' => "X-Paynet-Key: " . getenv('PAYNET_API_KEY')]])
), true);
if (($payment['status'] ?? null) === 'completed') { /* mark the order paid */ }
http_response_code(200);

Node.js

// Node.js (Express): keep the raw body for the HMAC, never a re-encoded one.
app.post('/webhooks/paynet', express.raw({ type: '*/*' }), async (req, res) => {
  const mine = crypto.createHmac('sha256', process.env.PAYNET_WEBHOOK_SECRET).update(req.body).digest('hex');
  const ok = [req.get('X-Paynet-Signature'), req.get('X-Paynet-Signature-Next')]
    .some(h => h && h.length === mine.length && crypto.timingSafeEqual(Buffer.from(h), Buffer.from(mine)));
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  // dedupe on event.delivery_id, then confirm:
  const r = await fetch(`https://paynet.am/api/v1/orders/${encodeURIComponent(event.transaction.order_id)}/payment`,
    { headers: { 'X-Paynet-Key': process.env.PAYNET_API_KEY } });
  const payment = await r.json();
  if (payment.status === 'completed') { /* mark the order paid */ }
  res.sendStatus(200);
});

Keep the secret and the API key in environment variables. Neither belongs in your repository or in browser code.

Retries, and what your endpoint owes them

Paynet waits 10 seconds for a 2xx response. It does not follow redirects, and it refuses to deliver to private addresses. If the delivery does not succeed, it is retried three times: after 1 minute, after 5 minutes and after 30 minutes.

Two consequences follow.

Answer fast, work afterwards. Verify, record, return 200. If you fulfil the order, send email and call your accounting system inside the request, you will eventually exceed 10 seconds and collect retries for work you already did. Queue that part.

Expect duplicates. A retry can happen because your response was slow rather than because you never received the event. The same event can therefore arrive twice, and it will carry the same delivery_id both times. Store the delivery_id and ignore one you have already processed. That is what idempotency means here, and it is the single most useful line of code in a webhook handler.

If a delivery for a completed or refunded payment fails for good, Paynet emails you, at most once per domain every six hours. That email means real money moved and your shop may not know about it.

Test mode and live mode

Every event carries livemode. Test payments made with a sk_test_ key or through the Sandbox provider fire webhooks with livemode: false, so you can prove the whole path end to end before a single real card is used.

Use that flag defensively. If your production order system ever receives an event with livemode: false, something is misconfigured, and it is better to reject it loudly than to mark a real order paid from a sandbox event.

Tools in the dashboard

You do not have to guess whether deliveries are working.

The Webhooks page shows how many domains have a webhook URL configured and how many deliveries succeeded and failed over the last seven days. The delivery log lists every attempt with the event, domain, order, result and time, including the ones that got no response at all. Any failed delivery can be retried from there.

There is also a Send test webhook button on the domain's webhook settings. It queues a delivery within a few seconds, and the result appears in the log. Use it when you first deploy your endpoint, and again after any change to your server, TLS certificate or firewall.

A short checklist for a correct handler

  1. Read the raw body before anything parses it.
  2. Compute the HMAC-SHA256 hex digest with your webhook secret.
  3. Compare in constant time against X-Paynet-Signature, and also against X-Paynet-Signature-Next.
  4. Reject with 401 if neither matches.
  5. Dedupe on delivery_id and stop if you have seen it.
  6. Return 200 immediately.
  7. In the background, call the order status endpoint and act on the status it returns.
  8. Never mark an order paid on the strength of the webhook body alone.

What to do next

  1. Set a webhook URL for your domain in the dashboard, or send callback_url when you create a payment.
  2. Implement the handler above and store the webhook secret in an environment variable.
  3. Press Send test webhook and check the delivery log.
  4. Make a sandbox payment and confirm your order moves to paid through the pull, not the push.
  5. Read the webhooks and lifecycle sections of the API reference, and the developer guide for the rest of the integration.

If you would rather not write any of this, the ready made store plugins already implement it.

Any provider or platform names mentioned belong to their owners. Paynet is an independent payment gateway.