---
updatedAt: 2026-10-16T12:59:06.000Z
agentTools:
  projectIndex: https://docs.infiniaweb.com/llms.txt
---

# 📩 Webhooks

Receive signed notifications when your operations change, verify them, and handle retries and duplicate deliveries.

Webhooks notify your application when something happens, such as a payin completing, a payout failing or a movement being posted, so you don't have to poll the API.

When an event occurs, Infinia sends a `POST` request with a JSON body to the URL you configured: the callback URL of the operation, or the account's webhook URL for movements. This page explains the headers of that request, how to verify its signature, how to avoid processing it twice, and how retries work.

## Request headers

Every webhook request includes these headers:

| Header                | Description                                                                                                                               |
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | Always `application/json`.                                                                                                                |
| `event`               | The type of object the webhook is about, in lowercase, for example `payin`, `payout` or `movement`.                                       |
| `X-Infinia-Signature` | Base64-encoded HMAC-SHA256 signature of the raw body. Use it to confirm the request comes from Infinia.                                   |
| `X-Idempotency-Key`   | A UUID that identifies this webhook. It stays the same when the webhook is delivered again. Use it to avoid processing the webhook twice. |

## Respond to a webhook

Respond with any `2xx` status code within 10 seconds to acknowledge the webhook. If your processing takes longer, store the webhook, respond, and process it afterwards.

## Verify the signature

Verify the `X-Infinia-Signature` header before you process a webhook. A valid signature confirms that the request comes from Infinia and that nobody changed the body on the way.

To verify it:

1. Compute an HMAC-SHA256 of the **raw request body**, using your **webhook signing key** as the key.
2. Base64-encode the result.
3. Compare it with the `X-Infinia-Signature` header using a constant-time comparison function. A regular string comparison (`==`) is vulnerable to timing attacks.

`X-Infinia-Signature` is always a Base64-encoded digest: 44 characters, ending in `=`. For example, `i7zDzV9RS0rI4W/9xIK6pjUTXFU1juKtdbH6+GGg9us=`.

<Callout icon="⚠️" theme="warning">
  Compute the signature over the **raw body bytes exactly as received**. Don't parse the JSON and serialize it again before verifying: key order and whitespace are part of the signed bytes, so a re-serialized body produces a different digest. Most web frameworks need an explicit opt-in to give you the raw body, for example `await request.body()` in FastAPI, `express.raw()` in Express or `@RequestBody byte[]` in Spring.
</Callout>

### Get your webhook signing key

Generate your signing key in the Infinia dashboard. Open **Administration → API Keys** and click **Generate webhook signing key**. The key is shown only once, so copy it and store it securely. If you don't have dashboard access, ask your Infinia integration contact to generate it for you.

Webhooks about a child company are signed with that company's signing key. If the child company has no key of its own, they're signed with the key of the nearest parent company that has one.

Generating a new key replaces the previous one immediately. Every webhook created from that moment on is signed with the new key, so update your verifier as part of the rotation. Automatic retries of a webhook created before the rotation, and resends of it through Update Webhook, keep the signature computed with the previous key. A resend from the dashboard is a new webhook, signed with the new key.

### Code examples

```python Python
import hmac
import base64
import hashlib

def is_valid_signature(raw_body: bytes, signature_header: str, signing_key: str) -> bool:
    computed = hmac.new(
        key=signing_key.encode('utf-8'),
        msg=raw_body,
        digestmod=hashlib.sha256
    ).digest()

    try:
        provided = base64.b64decode(signature_header)
    except Exception:
        return False

    return hmac.compare_digest(computed, provided)
```
```javascript Node.js
const crypto = require('crypto');

function isValidSignature(rawBody, signatureHeader, signingKey) {
  const computed = crypto
    .createHmac('sha256', signingKey)
    .update(rawBody)          // rawBody must be a Buffer or the exact received string
    .digest();

  let provided;
  try {
    provided = Buffer.from(signatureHeader, 'base64');
  } catch {
    return false;
  }

  if (computed.length !== provided.length) return false;
  return crypto.timingSafeEqual(computed, provided);
}
```
```java Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class WebhookVerifier {
    public static boolean isValidSignature(byte[] rawBody, String signatureHeader, String signingKey) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] computed = mac.doFinal(rawBody);

        byte[] provided;
        try {
            provided = Base64.getDecoder().decode(signatureHeader);
        } catch (IllegalArgumentException e) {
            return false;
        }

        if (computed.length != provided.length) return false;

        int result = 0;
        for (int i = 0; i < computed.length; i++) result |= computed[i] ^ provided[i];
        return result == 0;
    }
}
```
```php PHP
<?php
function isValidSignature(string $rawBody, string $signatureHeader, string $signingKey): bool {
    $computed = base64_encode(hash_hmac('sha256', $rawBody, $signingKey, true));
    return hash_equals($computed, $signatureHeader);
}
?>
```

### Test your implementation

Use this test vector to check your implementation before going live.

**Signing key**

```text
b4BETC3jwbUt2WntpgN3vhrjlCIytS3c
```

**Raw body.** 341 bytes, exactly as sent, with a space after each `:` and `,`:

```text
{"id": "338131", "account_id": "640", "company_id": "12344", "amount": 10, "currency": "MXN", "country": "MX", "balance": 10, "description": "Initial deposit", "created_at": "2025-08-11T16:05:06.749462", "updated_at": "2025-08-11T16:05:06.749462", "third_party": null, "operation": null, "company": {"id": "12344", "name": "InfiniaCompany"}}
```

**Expected `X-Infinia-Signature`**

```text
i7zDzV9RS0rI4W/9xIK6pjUTXFU1juKtdbH6+GGg9us=
```

## Avoid processing a webhook twice

Infinia can deliver the same webhook more than once, for example when it retries after a failed delivery or when you resend it through Update Webhook. Every delivery of the same webhook carries the same `X-Idempotency-Key`. Each status change of an operation is a separate webhook, with its own key. A resend from the dashboard is also a separate webhook: it carries the operation's current state and a new key.

To skip duplicates:

* Store the `X-Idempotency-Key` of every webhook you process, in a database or a cache, for at least 24 hours.
* Before you process a webhook, check whether you already handled its key.
* If you did, respond with a `2xx` right away without processing it again.

## Retries

If your endpoint is unreachable, doesn't respond within 10 seconds, or responds with a `5xx`, `408` or `429`, Infinia retries the delivery:

| Attempt | When                                   |
| :------ | :------------------------------------- |
| 1st     | When the event occurs                  |
| 2nd     | 15 minutes after the 1st attempt fails |
| 3rd     | 15 minutes after the 2nd attempt fails |

If the 3rd attempt fails, Infinia stops retrying and sets the webhook's status to `ERROR`. Any other `4xx` response sets the status to `ERROR` right away, without retries, because sending the same request again would get the same response.

To find webhooks that weren't delivered, use [List Webhooks](https://docs.infiniaweb.com/reference/v1_list_webhooks__get) filtered by `status=ERROR`. To resend one, set its status to `PENDING` with [Update Webhook](https://docs.infiniaweb.com/reference/v1_update_webhook__webhook_id___patch), or resend it from the dashboard.

## Source IP addresses

Infinia sends webhooks only from these IP addresses. You can use them in firewall rules or allowlists.

| Environment | IP addresses                             |
| :---------- | :--------------------------------------- |
| Production  | `54.152.206.172/32`, `34.192.190.151/32` |
| Sandbox     | `44.214.68.30/32`, `52.20.45.108/32`     |

<Callout icon="🔒" theme="warning">
  IP filtering alone isn't enough. Always verify the `X-Infinia-Signature` header, and treat IP filtering as an extra layer on top of it.
</Callout>

## Example webhook

```http
POST /your-webhook-endpoint HTTP/1.1
Host: example.com
Content-Type: application/json
event: movement
X-Infinia-Signature: i7zDzV9RS0rI4W/9xIK6pjUTXFU1juKtdbH6+GGg9us=
X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{"id": "338131", "account_id": "640", "company_id": "12344", "amount": 10, "currency": "MXN", "country": "MX", "balance": 10, "description": "Initial deposit", "created_at": "2025-08-11T16:05:06.749462", "updated_at": "2025-08-11T16:05:06.749462", "third_party": null, "operation": null, "company": {"id": "12344", "name": "InfiniaCompany"}}
```

Infinia sends the body on a single line, as shown above. Verify the signature against that single-line form, not against a formatted copy: the two produce different digests. The same body, formatted for reading:

```json
{
  "id": "338131",
  "account_id": "640",
  "company_id": "12344",
  "amount": 10,
  "currency": "MXN",
  "country": "MX",
  "balance": 10,
  "description": "Initial deposit",
  "created_at": "2025-08-11T16:05:06.749462",
  "updated_at": "2025-08-11T16:05:06.749462",
  "third_party": null,
  "operation": null,
  "company": {
    "id": "12344",
    "name": "InfiniaCompany"
  }
}
```