Webhooks let your application receive real-time notifications when something happens — a payment completes, a payout fails, a movement is posted — without polling the API.
When an event occurs, Infinia sends a POST request to the callback URL you configured, with a JSON payload describing the event.
Request headers
Every webhook request includes the following headers:
| Header | Description |
|---|---|
event | The type of event, e.g. payment, payout, movement |
X-Infinia-Signature | Base64-encoded HMAC-SHA256 signature of the raw body — use this to verify authenticity |
X-Idempotency-Key | A unique UUID for this delivery attempt — use this to prevent double-processing |
Verifying the signature
Always verify the X-Infinia-Signature header before processing a webhook. This confirms the request came from Infinia and the payload hasn't been tampered with.
How it works:
- Compute an HMAC-SHA256 of the raw request body using your webhook signing key as the key
- Base64-encode the result
- Compare it to the
X-Infinia-Signatureheader using a time-safe comparison
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 — copy it and store it securely. If you don't have dashboard access, ask your Infinia integration contact to generate it for you.
Generating a new key replaces the previous one immediately: every webhook sent from that moment on is signed with the new key, so update your verifier as part of the rotation.
X-Infinia-Signature is always a base64-encoded digest: 44 characters, ending in =. For example, i7zDzV9RS0rI4W/9xIK6pjUTXFU1juKtdbH6+GGg9us=.
Sign the raw body bytes exactly as received. Do not parse the JSON and re-serialize it before verifying — key order and whitespace are part of the signed bytes, and any re-serialization will produce a different digest. Most web frameworks require an explicit opt-in to access the unparsed body (e.g.
await request.body()in FastAPI,express.raw()in Express,@RequestBody byte[]in Spring).
Always use a time-safe comparison function. Regular string equality (
==) is vulnerable to timing attacks.
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 vector
Use this to confirm your implementation before going live.
Signing key
b4BETC3jwbUt2WntpgN3vhrjlCIytS3c
Raw body (341 bytes, exactly as sent on the wire — note the 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=
Preventing duplicate processing
Each delivery attempt includes a unique X-Idempotency-Key. If Infinia retries a failed delivery, the same key is reused, letting you detect and skip duplicates.
Recommended approach:
- Store processed idempotency keys (e.g. in a database or cache) for at least 24 hours
- Before processing a webhook, check if its key has already been handled
- If it has, return a
2xxresponse immediately without re-processing
Retry policy
If your server does not respond with a 2xx status code, or is unreachable, Infinia will retry delivery automatically.
| Attempt | Timing |
|---|---|
| 1st retry | 15 minutes after initial failure |
| 2nd retry | 15 minutes after 1st retry |
| 3rd retry | 15 minutes after 2nd retry |
| 4th retry | 15 minutes after 3rd retry |
After 4 failed retries, the webhook is marked as ERROR and no further automatic retries are made. You can manually resend any webhook in ERROR state via the API or dashboard.
Source IP addresses
Infinia sends webhooks exclusively from the following IP addresses. You can use these to configure firewall rules or allowlists.
Production
54.152.206.172/3234.192.190.151/32
Sandbox
44.214.68.30/3252.20.45.108/32
IP filtering alone is not sufficient — always verify the
X-Infinia-Signatureheader. IP addresses should be treated as an additional layer, not a replacement for signature verification.
Example payload
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"}}The body is sent on a single line, as shown above. The same payload, formatted for readability:
{
"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"
}
}Verify the signature against the single-line form actually received, not the formatted form. The two produce different digests.

