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:
- Compute an HMAC-SHA256 of the raw request body, using your webhook signing key as the key.
- Base64-encode the result.
- Compare it with the
X-Infinia-Signatureheader 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
b4BETC3jwbUt2WntpgN3vhrjlCIytS3cRaw 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-Keyof 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
2xxright 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 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.
| 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 |
IP filtering alone isn't enough. Always verify the
X-Infinia-Signatureheader, 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"
}
}
