# Guide to PayTabs Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `signature` |
| Body | JSON |

From PayTabs' [IPN and callback article](https://support.paytabs.com/en/support/solutions/articles/60000978877):

* 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:

| Field | Values seen in the docs | Use |
| --- | --- | --- |
| `tran_type` | `Sale`, `Refund` (other types such as `Auth` exist) | What kind of transaction this is |
| `payment_result.response_status` | `A` authorised, `D` declined (see PayTabs' Response_code and Response_status article for the rest) | Outcome |
| `payment_result.response_message` | `Authorised`, `Acquirer is unable to process this request` | Human-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](https://github.com/hookdeck/webhook-samples/blob/main/providers/paytabs/latest/transaction.json):

```json
{
  "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:

```bash
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](https://support.paytabs.com/en/support/solutions/articles/60000718961) 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

```javascript
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.

```javascript
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](/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) 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](/webhooks/guides/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](https://hookdeck.com) verifies PayTabs notifications, keeps them for replay, and routes refunds and declines where they need to go.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle PayTabs webhooks reliably in minutes.