Gareth Wilson Gareth Wilson

Guide to PayTabs Webhooks: Features and Best Practices

Published


PayTabs is a payment gateway for merchants in the Middle East and North Africa. When you integrate through its PT2 API, PayTabs reports each transaction to your server with a signed POST of the full transaction record, which is what you use to mark orders paid, refunded or declined.

Here we look at PayTabs' two server-to-server notifications, the fields that tell you what happened to a payment, how the ServerKey signature works, and what PayTabs leaves undocumented about delivery.

What are PayTabs webhooks?

PayTabs doesn't call them webhooks. It has two notification types, and both send the same transaction JSON:

  • IPN (Instant Payment Notification). Sent to the IPN listener URL configured on your profile "as soon as a transaction is created or altered". One payment can therefore produce several IPNs, and later changes such as refunds produce more.
  • Callback. A one-time IPN sent to the callback URL you pass on the payment request, once the payment process ends. PayTabs sends it whether the customer pays, fails to pay or cancels, and it doesn't depend on the customer returning to your site.

The Return URL is something else: the customer's browser is redirected there with form fields such as respStatus and tranRef, signed with a different scheme over the sorted fields. It's useful for showing a result page but isn't a server notification, and this guide doesn't cover it.

PayTabs webhook features

FeatureDetails
Signaturehex HMAC-SHA256
Signature headersignature
BodyJSON

From PayTabs' IPN and callback article:

  • Both notifications are server-to-server POST requests, not browser requests.
  • The callback URL must be "an available, accessible, and public URL" over HTTPS.
  • Because the request comes from an external system, PayTabs tells you to exempt the route from CSRF protection.
  • PayTabs points you at response_status and response_message to work out the transaction's state.

Events and payloads

There are no event names. Every notification is the current state of one transaction, so you route on the transaction type and the result:

FieldValues seen in the docsUse
tran_typeSale, Refund (other types such as Auth exist)What kind of transaction this is
payment_result.response_statusA authorised, D declined (see PayTabs' Response_code and Response_status article for the rest)Outcome
payment_result.response_messageAuthorised, Acquirer is unable to process this requestHuman-readable reason

The full list of response codes is in PayTabs' Response_code and Response_status article. Other fields you'll use are tran_ref (PayTabs' transaction reference), cart_id (your own order reference from the payment request), the amount and currency fields, and ipn_trace.

This declined Sale is the sample from PayTabs' documentation, as filed in the webhook samples library:

{
  "tran_ref": "TST2234801409691",
  "merchant_id": 31237,
  "profile_id": 81784,
  "cart_id": "cart_11111",
  "cart_description": "Description of the items/services",
  "cart_currency": "EGP",
  "cart_amount": "500.00",
  "tran_currency": "EGP",
  "tran_total": "500.00",
  "tran_type": "Sale",
  "tran_class": "ECom",
  "customer_details": {
    "name": "Mohammed EL Rayes",
    "email": "mohammed.elrayes@paytabs.com",
    "phone": "01008606003",
    "street1": "AAA, A, BBB",
    "city": "CCC",
    "state": "C",
    "country": "EG",
    "zip": "42121",
    "ip": "156.221.244.35"
  },
  "shipping_details": {
    "name": "Mohammed EL Rayes",
    "email": "mohammed.elrayes@paytabs.com",
    "phone": "01008606003",
    "street1": "AAA, A, BBB",
    "city": "CCC",
    "state": "C",
    "country": "EG",
    "zip": "42121"
  },
  "payment_result": {
    "response_status": "D",
    "response_code": "344",
    "response_message": "Acquirer is unable to process this request",
    "acquirer_message": "ERR_00:Technical Failure",
    "cvv_result": " ",
    "avs_result": " ",
    "transaction_time": "2022-12-14T15:53:58Z"
  },
  "payment_info": {
    "payment_method": "Visa",
    "card_type": "Credit",
    "card_scheme": "Visa",
    "payment_description": "4111 11## #### 1111",
    "expiryMonth": 12,
    "expiryYear": 2022
  },
  "ipn_trace": "IPNS0004.6399F196.00000FD3"
}

Note that cart_amount and tran_total are strings, while merchant_id and profile_id are numbers. payment_info.payment_description holds a masked card number, and customer_details includes the shopper's email, phone, address and IP, so keep these bodies out of general logs.

Setting up PayTabs webhooks

  1. Find your profile's ServerKey in the PayTabs merchant dashboard. It's the key you already use to authenticate PT2 API calls, and it's also the signing key, so there is no separate webhook secret to generate. Store it as PAYTABS_SERVER_KEY.
  2. For per-payment notifications, set callback on each payment request to your HTTPS endpoint, for example https://example.com/webhooks/paytabs.
  3. For notifications on every transaction change, configure the IPN listener URL on the profile, following PayTabs' "How to configure Instant payment notification (IPN)" article.
  4. Exempt the route from CSRF middleware if your framework applies it.

You can point both at the same handler, since the body format and signature are identical. To receive through Hookdeck, create a source of the PayTabs type, give it the ServerKey, and use the source URL as the callback and IPN URL. To test locally:

npx hookdeck-cli listen 3000 paytabs --path /webhooks/paytabs

Then run a test-mode payment and check that both the callback and the IPN arrive.

Securing PayTabs webhooks

PayTabs sends a Signature header containing the HMAC-SHA256 of the entire request body, keyed with the ServerKey and hex-encoded. Its signature verification article shows the PHP version: hash_hmac('sha256', $payload, $serverKey) compared with hash_equals. Hash the raw bytes before any JSON parsing, and use the ServerKey exactly as shown, as UTF-8 text.

Node.js verification

const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

The handler below verifies the request, then records the transaction's latest state keyed by tran_ref. Because the IPN fires on every change, the same transaction can arrive several times with different results.

const express = require('express');

const app = express();

app.post('/webhooks/paytabs', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.PAYTABS_SERVER_KEY;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  let tran;
  try {
    tran = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!tran || typeof tran !== 'object' || Array.isArray(tran)) return res.sendStatus(400);
  try {
    const status = tran.payment_result?.response_status;
    const key = `${tran.tran_ref}:${status}`;

    if (await alreadyApplied(key)) return res.sendStatus(200);

    if (status === 'A' && tran.tran_type === 'Sale') await markOrderPaid(tran.cart_id, tran);
    else if (status === 'A' && tran.tran_type === 'Refund') await recordRefund(tran.cart_id, tran);
    else await recordPaymentResult(tran.cart_id, tran);

    await markApplied(key);
  } catch {
    return res.sendStatus(503);
  }

  res.sendStatus(200);
});

Paste your PayTabs profile server key. Only callback (IPN) requests are supported, not the browser return URL.

Hookdeck's PayTabs source checks the same Signature header. It answers PayTabs with 200 straight away and records any mismatch as failed verification instead of returning 401. No timestamp is involved, because PayTabs doesn't sign one.

Make PayTabs webhooks production-ready. Hookdeck Event Gateway keeps every IPN and callback, so a payment notification that reaches your server while it's down can be retried or replayed instead of leaving an order unpaid.

PayTabs webhook limitations and pain points

The signing key is your API key

The Problem: Anything that verifies PayTabs notifications must hold the ServerKey, which can also call the PT2 API on your behalf.

Why It Happens: PayTabs signs the Signature header with the profile ServerKey and has no separate webhook secret.

Workarounds:

  • Keep the key in a secrets manager and give the webhook service read access only to that value.
  • If a verifier leaks the key, treat it as a compromised API credential, not just a webhook secret.

How Hookdeck Can Help: If Hookdeck verifies the signature, your receiving service doesn't need the ServerKey at all, provided you secure the Hookdeck-to-service hop separately.

Delivery behaviour is undocumented

The Problem: You can't tell from the docs whether a failed IPN or callback is retried, how long PayTabs waits, or what response it expects.

Why It Happens: PayTabs' articles describe when notifications are sent and how to verify them, but say nothing about retries, timeouts or response codes. The Content-Type header isn't stated either.

Workarounds:

  • Return 200 quickly and do the order update after storing the notification.
  • Flag orders that stay pending well past the time you'd expect a callback, and check them in PayTabs.

How Hookdeck Can Help: Hookdeck accepts each notification at once and keeps it, so an outage on your side doesn't depend on whether PayTabs would have tried again.

Several notifications per transaction

The Problem: One payment can trigger the callback and one or more IPNs, and later changes trigger more.

Why It Happens: The callback fires once at the end of the payment, while the IPN fires whenever the transaction is created or altered. If both URLs point at you, you get both.

Workarounds:

  • Key processing on tran_ref plus payment_result.response_status, so a repeat of the same state is ignored but a new state is applied.
  • Choose whether the callback or the IPN drives fulfilment, and treat the other as confirmation.

How Hookdeck Can Help: Hookdeck's deduplication can drop identical bodies arriving within a time window, while still passing through a changed status for the same transaction.

No event name to route on

The Problem: A refund, an authorisation and a declined sale all arrive at the same URL in the same shape.

Why It Happens: PayTabs sends the transaction itself, not an event. Meaning comes from tran_type and response_status.

Workarounds:

  • Branch on both fields, and log any combination you don't handle instead of failing.
  • Use cart_id to find your order, and tran_ref when a refund relates to an earlier sale.

How Hookdeck Can Help: Hookdeck filters can match on tran_type and payment_result.response_status, so refunds and declines can go to different destinations.

Best practices

Keep the Return URL separate

The Return URL uses form fields and a different signature calculation. Don't send it to this handler, and don't fulfil orders from it alone. Wait for the signed server notification.

Store the latest state per transaction

Write each verified notification against tran_ref, so a late IPN for an older state can't overwrite a newer one. Compare payment_result.transaction_time before replacing a stored result.

Reply first, fulfil afterwards

Since PayTabs doesn't say how long it waits, store the body and acknowledge before calling inventory or shipping systems. Our guide on processing webhooks asynchronously shows the queue-based pattern.

Make order updates safe to repeat

Marking an order paid twice should be harmless. See how to implement webhook idempotency for ways to record what you've already applied.

Conclusion

PayTabs reports payments through a one-time callback and an IPN on every transaction change, both carrying the full transaction and a hex HMAC-SHA256 Signature keyed with your ServerKey. With no event names and no documented retry policy, the work is in routing on tran_type and response_status, deduplicating on tran_ref, and protecting a signing key that doubles as your API credential.

Hookdeck Event Gateway verifies PayTabs notifications, keeps them for replay, and routes refunds and declines where they need to go.

Get started with Hookdeck for free and handle PayTabs webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.