---
title: "Security"
description: "Verify webhook signatures and handle retries"
---

> For the complete documentation index, see [llms.txt](/llms.txt).

Every webhook from iwoca includes a cryptographic signature so you can verify it's genuine before processing.
Signatures use a Hash-based Message Authentication Code (HMAC), which proves both that the payload is unchanged and that it came from someone holding your secret token.

## Signature verification

Each webhook includes two headers:

| Header | Purpose |
|---|---|
| `X-IW-Signature` | HMAC-SHA256 signature of the request |
| `X-IW-Timestamp` | Unix timestamp when the webhook was sent |

The signature is computed as:

```text
HMAC-SHA256(secret_token, timestamp + "." + request_body)
```

The result is base64-encoded and prefixed with `sha256=`, so the header looks like `sha256=YoSHTwbVZnuLY8kPusTeHoqcyQ9g1IeVHKpIFLJjp4k=`.

<Note>
A small number of older integrations are still on SHA-1, where the signature is `sha1=` + base64 of `HMAC-SHA1(secret_token, request_body)` – over the body alone, with no timestamp. `GET /webhooks/configuration/` returns an `encryption_method` of `sha256` or `sha1` so you can check which applies to you.
Everything below assumes `sha256`; if you are on `sha1`, talk to your Partnership Manager about moving across.
</Note>

## Code examples

<Tabs>
  <Tab title="Python">
    ```python
    import base64
    import hmac
    import hashlib
    import time

    def verify_webhook(request_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
        # Reject old webhooks (replay protection)
        if abs(time.time() - int(timestamp)) > 300:
            return False

        digest = hmac.new(
            secret.encode(),
            f"{timestamp}.".encode() + request_body,
            hashlib.sha256
        ).digest()

        # The header is base64-encoded and prefixed with "sha256=" —
        # not a hex digest.
        expected = "sha256=" + base64.b64encode(digest).decode()

        return hmac.compare_digest(expected, signature)
    ```
  </Tab>
  <Tab title="Node.js">
    ```javascript
    const crypto = require('crypto');

    function verifyWebhook(body, signature, timestamp, secret) {
      // Reject old webhooks (replay protection)
      if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
        return false;
      }

      // The header is base64-encoded and prefixed with "sha256=" —
      // not a hex digest.
      const expected =
        'sha256=' +
        crypto
          .createHmac('sha256', secret)
          .update(`${timestamp}.${body}`)
          .digest('base64');

      const a = Buffer.from(expected);
      const b = Buffer.from(signature);
      // timingSafeEqual throws if the lengths differ.
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }
    ```
  </Tab>
</Tabs>

## Replay protection

Use `X-IW-Timestamp` to reject stale webhooks.
We recommend rejecting any webhook where the timestamp is more than five minutes old.
This prevents replay attacks where a captured webhook is resent later.

## Retry behaviour

A delivery counts as successful when your endpoint responds with a status below 400. Anything else – a 4xx, a 5xx, a connection error, or a timeout – is a failed attempt and is retried.

By default iwoca retries up to **10 times after the first attempt, so 11 attempts in all**, spread over roughly **16 hours**:

| Attempt | Sent after the previous attempt |
|---|---|
| 1 | – (immediately after the event) |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 5 minutes |
| 6 | 15 minutes |
| 7 | 30 minutes |
| 8 | 1 hour |
| 9 | 2 hours |
| 10 | 4 hours |
| 11 | 8 hours |

A few seconds of random jitter is added to each gap, so treat the times as approximate.
Each attempt waits up to **10 seconds** for your response before being treated as a timeout.

<Note>
These are the default timings.
The schedule is configurable per partner.
A custom one changes both the intervals **and the number of attempts**, so confirm yours with your Partnership Manager if your integration depends on either.
</Note>

### What each retry looks like

- `X-IW-Event-ID` stays the **same** across every attempt of the same event – use it as your idempotency key.
- `X-IW-Timestamp` and `X-IW-Signature` are **regenerated on every attempt**, because the timestamp is part of the signed payload.

<Warning>
Because each attempt carries a fresh timestamp, your signature verification must use the timestamp from the request in hand – never a cached value from an earlier attempt.
</Warning>

<Warning>
**Point your subscription at the final URL, not a redirect.**
Redirects are followed, but a `301`, `302` or `303` is followed as a bodyless `GET`.
Your handler still receives the `X-IW-*` headers, but the event payload is gone.
The redirect target's `2xx` makes the delivery look successful to iwoca, so it is never retried.
`307` and `308` do replay the full `POST`.
If the redirect crosses to another host, the signature headers are forwarded to that host as well.
Register the exact HTTPS URL you want to receive events on.
</Warning>

### After the last attempt

If all attempts fail, iwoca raises an internal alert and the delivery stops. **Your subscription stays active** – it is not disabled automatically, and events after that point are still delivered normally.
Nothing is re-sent to you automatically.
If your endpoint has been down, reconcile by calling the relevant `GET` endpoints — your Partnership Manager can arrange a manual redelivery if you need one.

<Note>
A manual redelivery arrives as a **separate event with its own `X-IW-Event-ID`**, so it will not match the ID of the original attempt.
If you need to detect that specific case, compare the payload fields as well as the event ID.
</Note>

<Note>
Test webhooks sent from the Developer Portal are attempted **once** and never retried, so you will not see retry behaviour while debugging there.
</Note>

## Idempotency

The same event may be delivered more than once.
Your webhook handler should be idempotent – processing the same event twice should not cause issues.
Use the `X-IW-Event-ID` header (a unique ID per webhook) as your idempotency key; fall back to the combination of `event_type`, `timestamp`, and the data fields if needed.

## IP allowlisting

If you need IP-based filtering, contact your Partnership Manager for iwoca's webhook IP addresses.
