šŸ“© 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:

HeaderDescription
Content-TypeAlways application/json.
eventThe type of object the webhook is about, in lowercase, for example payin, payout or movement.
X-Infinia-SignatureBase64-encoded HMAC-SHA256 signature of the raw body. Use it to confirm the request comes from Infinia.
X-Idempotency-KeyA 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=.

āš ļø

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.

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

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)
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);
}
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
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

b4BETC3jwbUt2WntpgN3vhrjlCIytS3c

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

{"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

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:

AttemptWhen
1stWhen the event occurs
2nd15 minutes after the 1st attempt fails
3rd15 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 filtered by status=ERROR. To resend one, set its status to PENDING with Update Webhook, 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.

EnvironmentIP addresses
Production54.152.206.172/32, 34.192.190.151/32
Sandbox44.214.68.30/32, 52.20.45.108/32
šŸ”’

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.

Example webhook

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:

{
  "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"
  }
}