# Guide to Nomod Webhooks: Features and Best Practices

[Nomod](https://nomod.com) lets small and medium businesses in the UAE take card payments through payment links, a hosted checkout and an API. Card payments go through several states, from created to authorised, captured, refunded or disputed, and Nomod's webhooks report each change on a charge so you can fulfil orders, reconcile your balance and handle refunds without polling.

## What are Nomod webhooks?

Each Nomod webhook is tied to one **integration type**: **Nomod API**, for transactions you process through the API, or **Nomod Hosted Checkout**, for those that go through Nomod's hosted payment page. If you use both, create a webhook for each. Delivery and signing are handled by Svix, and Nomod's verification guide recommends the Svix library.

| Aspect | Value |
| --- | --- |
| Method | `POST`, JSON body (Nomod prints no `Content-Type`, so read the raw bytes whatever it is) |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` (Standard Webhooks) |
| Success | `200 OK`, returned as quickly as possible. A slow response counts as a failure and is retried |
| Deduplication | `eventId`, to ignore repeated deliveries |
| Event logs | Kept for 90 days |

Nomod's docs don't give a retry schedule or a response timeout, so don't build either into your code.

## Nomod webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard, Owners and Admins only |
| Scope | One integration type per webhook, with chosen events |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature` |
| Secret | Per webhook, `whsec_...` |
| Rotation | Old secret stays valid for 24 hours |
| Body | JSON |
| Event routing | Body field `type` |
| Retries | Automatic, schedule not published |
| Event logs | 90 days |

For complete handlers covering all eight events, see the [Nomod skill examples](https://github.com/hookdeck/webhook-skills/tree/main/skills/nomod-webhooks/examples).

## Events and payloads

Every event carries the full charge:

| Field | Meaning |
| --- | --- |
| `type` | The event name. Branch on this |
| `eventId` | Id of this event. Use it to detect and ignore repeated deliveries |
| `objectId` | The charge the event is about (`ch_...`) |
| `data` | The full charge: `total`, `currency`, `status`, `customer`, `items`, `fee`, `net`, `paymentMethod`, `refundTotal`, `refunds`, `referenceId`, `error` and more |

`data.events[].type`, for example `completed`, is the charge's own history, **not** the webhook event type. Amounts are decimal strings, so parse them with a decimal type rather than floating point. Nomod's [payload example](https://nomod.com/docs/webhooks/how-webhooks-work) shows every field.

Nomod documents eight events, and you choose which ones each webhook receives:

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `charge.created` | A payment object has been created and is awaiting customer action | Track pending payments |
| `charge.authorised` | Funds are reserved on the customer's card and awaiting capture | Hold stock, capture later |
| `charge.completed` | Funds are captured and settled to your balance | Fulfil the order, reconcile |
| `charge.failed` | The payment was declined at authorisation or capture | Read `data.error`, notify the customer |
| `charge.cancelled` | The authorisation was voided before capture | Release held stock |
| `charge.refunded` | A full refund has been issued | Refund bookkeeping |
| `charge.partially_refunded` | A partial refund has been issued; the remaining amount stays settled | Adjust the order total |
| `charge.dispute.created` | A chargeback or dispute was opened by the customer | Gather evidence, flag the order |

Only `charge.completed` has a published example. The envelope above applies to all eight, but Nomod doesn't show the other seven, so log real deliveries before relying on fields such as `error` or `refunds`.

## Setting up Nomod webhooks

You need to be an **Owner or Admin** on the Nomod account; Managers and Staff don't see the Webhooks option.

1. In the Nomod dashboard, go to **Settings > Tools & customisations > Apps & APIs**.
2. Select **Webhooks > Create webhook**.
3. Choose **Nomod API** or **Nomod Hosted Checkout** and tap **Continue**.
4. Select the events and tap **Continue**. An event can be added to more than one webhook.
5. Enter a name and the endpoint URL, then tap **Create webhook**. Delivery starts straight away.

Then open the webhook and copy the masked **Signing secret** into `NOMOD_WEBHOOK_SECRET`, keeping the `whsec_` prefix:

```bash
NOMOD_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx

```

To put Hookdeck in front, create a source with the **Nomod** type, paste the secret, and enter the source URL as the webhook's URL. During development, the [Hookdeck CLI](/docs/cli) forwards it to your machine:

```bash
npx hookdeck-cli listen 3000 nomod --path /webhooks/nomod

```

To rotate the secret, select the webhook, tap the rotate icon next to **Signing secret**, and confirm with **Generate**. The new secret applies immediately and the old one keeps working for 24 hours, so `svix-signature` may carry two `v1,` entries during that time. Update your stored secret within the window.

## Securing Nomod webhooks

Nomod's verification page spells out the construction: `signedContent = svix-id + "." + svix-timestamp + "." + rawBody`, HMAC-SHA256 with the base64 portion of the secret after `whsec_`, base64-encoded and compared with each space-delimited `v1,` entry. This function does that and also reads `webhook-*` header names:

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

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Nomod also tells receivers to reject requests more than five minutes old. Behind Hookdeck that rule would reject genuine payments, because Hookdeck has no timestamp tolerance and resends the original `svix-timestamp` when it retries or replays. The function above checks the signature only.

Nomod counts a slow response as a failure, so the receiver stores the charge event and returns at once:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// chargeEvents.save(eventId, event) stores the event once and resolves when durable.
function createNomodReceiver(chargeEvents) {
  const app = express();
  app.post('/webhooks/nomod', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.NOMOD_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    try {
      await chargeEvents.save(event.eventId, event);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

When Nomod sends to a Hookdeck source, a request with a bad signature still gets `200`. Hookdeck flags it as failed verification and doesn't forward it.

> **Make Nomod webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies each charge event, keeps a searchable history beyond your own logs and retries your service on a schedule you can see.

## Nomod webhook limitations and pain points

### No published retry policy

**The Problem:** You can't tell how long Nomod will keep trying after an outage, or whether it ever gives up on an endpoint.

**Why It Happens:** Nomod says failed deliveries are retried automatically, but doesn't publish a schedule, a timeout or a disabling policy.

**Workarounds:**

* Return `200` as soon as the event is stored.
* Check Nomod's event logs, kept for 90 days, after an outage, and reconcile charges through the API.

**How Hookdeck Can Help:** Hookdeck acknowledges Nomod at once and [retries](/docs/retries) your service on a policy you set and can see.

### One integration type per webhook

**The Problem:** Hosted checkout payments don't reach the webhook you set up for API payments.

**Why It Happens:** Each webhook belongs to exactly one integration type.

**Workarounds:**

* Create a webhook for each integration type you use.

**How Hookdeck Can Help:** Both webhooks can point at one Hookdeck source, so your service gets a single stream.

### Seven events have no example

**The Problem:** Your `charge.failed` or `charge.dispute.created` handler is written blind.

**Why It Happens:** Nomod publishes a full example for `charge.completed` only.

**Workarounds:**

* Log the first real delivery of each event type and check its fields before you depend on them.

**How Hookdeck Can Help:** Hookdeck stores every payload it receives, so you can inspect real `charge.failed` and dispute events as they happen.

### Five-minute window in the recommended library

**The Problem:** Replayed or delayed payment events fail verification.

**Why It Happens:** Nomod recommends the Svix library and a five-minute window, and the library enforces it.

**Workarounds:**

* Use a signature-only check, like the one above, for anything that can be delayed or replayed.

**How Hookdeck Can Help:** Hookdeck checks the signature itself and has no timestamp window, so replays from its log reach your service.

## Best practices

### Fulfil on `charge.completed`

`charge.authorised` only reserves funds. Ship goods or grant access when funds are captured, which is `charge.completed`.

### Release stock on cancel and failure

`charge.cancelled` means an authorisation was voided, and `charge.failed` a decline. Read `data.error` on failures before messaging the customer.

### Act on disputes quickly

`charge.dispute.created` means a chargeback has been opened. Flag the order and start gathering evidence.

### Deduplicate on `eventId`

Nomod tells you to use `eventId` to ignore repeated deliveries. Store it with the change it causes; see our [walkthrough of webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Nomod's webhooks follow each charge through capture, refund and dispute, with Svix signing and a 24-hour rotation window. Only one of the eight events has an example, so log real payloads early.

[Hookdeck Event Gateway](https://hookdeck.com) adds a retry policy you control and a record of every charge event. [Get started with Hookdeck](https://dashboard.hookdeck.com/signup).