Webhooks
Geat.io posts a signed JSON body to your callback_url when something
happens to an invoice or an address request.
- ·Invoices:
callback_urlis required at creation, so every invoice has a webhook target. See Invoices. - ·Address requests:
callback_urlis optional. Without one, no webhook is sent for that address. See Addresses.
The four events
These are the only events the product emits. There are no others - no withdrawal, refund, partial-payment or confirmation-step webhooks exist on the merchant surface today.
| Event | Fired when |
|---|---|
invoice.completed | An invoice reached its confirmation threshold and was credited. |
invoice.expired | An invoice's payment window closed. |
address.paid | The first confirmed payment credited to an address request. |
address.payment | Every subsequent confirmed payment credited to that same address request. |
An address request is a reusable deposit endpoint, which is why the second and later credits get their own event name rather than repeating the first one.
Delivery is per event, not per entity: an invoice may legitimately receive an expiry callback and, later, a completion callback if a late payment is credited. Within one event name, an entity is enqueued at most once.
Invoice payload
{
"event": "invoice.completed",
"invoice_uuid": "0f0f6a6c-7e2e-4a4e-9f4b-9a6d0f3a1b22",
"invoice_id": "ORDER-1042",
"status": "completed",
"price": "49.00",
"currency": "USD",
"customer_email": "payer@example.com",
"coin": { "symbol": "BTC", "name": "Bitcoin", "network": "bitcoin" },
"gross_amount": "0.000800210000000000",
"fee_amount": "0.000000000000000000",
"net_amount": "0.000800210000000000",
"timestamp": "2026-10-14T09:31:22.004112+00:00"
}
| Field | Type | Notes |
|---|---|---|
event | string | One of the two invoice events above. |
invoice_uuid | string | Our identifier - use it against GET /v1/invoices/{invoice_uuid}. |
invoice_id | string | Your own reference from the create call. |
status | string | completed or expired. Mirrors the event; the internal expired_partial is never sent. |
price | string | Fiat amount, 2 decimal places. |
currency | string | Fiat symbol. |
customer_email | string or null | |
coin | object | symbol, name, network. Each may be null. |
gross_amount | string | Sum of credited transactions, 18 decimal places. "0.0" on expiry. |
fee_amount | string | Always zero - the product charges credits, not payment fees. "0.0" on expiry. |
net_amount | string | Same as gross today. "0.0" on expiry. |
timestamp | string | ISO 8601 UTC, generated when the payload is built. |
A completion payload is only built once at least one credited transaction exists; the expiry payload reports zero amounts.
Address request payload
{
"event": "address.paid",
"address_uuid": "6b1f1b6c-2f3a-4c57-8d1a-1d1e9e7a55c1",
"address": "bc1q...",
"status": "paid",
"coin": { "symbol": "BTC", "name": "Bitcoin", "network": "bitcoin" },
"txid": "9a1b...",
"gross_amount": "0.002000000000000000",
"fee_amount": "0.000000000000000000",
"net_amount": "0.002000000000000000",
"tx_count": 1,
"total_received": "0.002000000000000000",
"timestamp": "2026-10-14T09:31:22.004112+00:00"
}
| Field | Type | Notes |
|---|---|---|
event | string | address.paid for the first credit, address.payment for every later one. |
address_uuid | string | Use it against GET /v1/addresses/{address_uuid}. |
address | string | The receive address, verbatim. |
status | string | Always paid on these events. |
coin | object | symbol, name, network. Each may be null. |
txid | string | Which on-chain transaction this delivery is about. |
gross_amount | string | This transaction's amount, 18 decimal places. |
fee_amount | string | Always zero. |
net_amount | string | This transaction's credited amount. |
tx_count | integer | Running number of credited transactions on this address, including this one. |
total_received | string | Running sum of credited net_amounts, 18 decimal places. |
timestamp | string | ISO 8601 UTC. |
One delivery per credited transaction. tx_count and total_received
carry the running state so a multi-payment integrator never has to add
the deliveries up itself.
Request format and signing
Each delivery is an HTTP POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Signature | sha256=<hex digest> |
X-Timestamp | Unix seconds, as a decimal string |
User-Agent | geatio-callback/1.0 |
The body is compact JSON (no spaces after separators). Verify against the raw bytes you received, never a re-serialized copy.
Where the secret comes from
The webhook secret is per payment profile, generated when the profile
is created, and formatted whsec_ followed by 64 hex characters. You
read it and rotate it from that profile's settings in the dashboard,
which is backed by GET /profiles/{profile_uuid}/webhook-secret and
POST /profiles/{profile_uuid}/webhook-secret/rotate - dashboard
(session-authenticated) endpoints, not part of the API-key /v1
surface, and rotation additionally requires your TOTP code when 2FA is
enabled.
Rotation takes effect immediately. Each delivery attempt is signed at send time with the profile's current secret, so a callback still waiting on a retry goes out signed with the new secret - which is one reason 401 and 403 are retryable rather than fatal.
The signature is an HMAC-SHA256 over the string
{timestamp}.{raw_body}
keyed with your profile's webhook secret, hex-encoded and prefixed with
sha256=. In other words: take the X-Timestamp header value, a literal
., then the raw request body.
$expected = 'sha256=' . hash_hmac(
'sha256',
$timestampHeader . '.' . $rawBody,
$webhookSecret
);
// compare with hash_equals($expected, $signatureHeader)
import hashlib, hmac
expected = 'sha256=' + hmac.new(
secret.encode(),
f'{timestamp_header}.'.encode() + raw_body,
hashlib.sha256,
).hexdigest()
# compare with hmac.compare_digest(expected, signature_header)
Always compare in constant time.
Timestamp tolerance
X-Timestamp exists so a captured delivery cannot be replayed later.
Reject a delivery whose timestamp is too far from your own clock. The
official PHP SDK defaults to a 300 second tolerance either way
(Geatio\Webhook\Verifier::DEFAULT_TOLERANCE_SECONDS), which is the
recommended value. The tolerance is enforced by the receiver, not by us.
Notifications are not webhooks
Discord and Telegram notification deliveries use the same dispatcher but
are sent unsigned - those services authenticate through their own URL
or token. Only merchant callbacks carry X-Signature.
Responding, retries and failure
Answer any 2xx to acknowledge. The body is ignored.
| Response | Treatment |
|---|---|
| 2xx | Delivered. Done. |
| 3xx | Retryable. Redirects are not followed, so a redirect is taken as the final answer and retried like a 5xx until the budget runs out. Point callback_url at the final URL, not at something that redirects. |
| 401, 403, 408, 429 | Retryable. These are the auth-ish and transient codes - a rotated webhook secret or a WAF answering during a deploy must not burn the callback on the first attempt. |
| Any other 4xx | Terminal. Marked failed immediately, no retries. |
| 5xx or a network error | Retryable. |
Only the "any other 4xx" row is terminal on the first attempt. Everything that is neither 2xx nor a terminal 4xx goes down the backoff path below.
Retry delay is min(5 * 2^(attempt - 1), 120) minutes plus up to 25%
random jitter - so 5 minutes, then 10, then 20, 40, 80, capped at 2
hours. The attempt budget is 3 by default
(CALLBACK_MAX_RETRIES), which puts the whole retry window at roughly 15
to 19 minutes from the first attempt. When the budget is exhausted the
callback is marked failed and the merchant is emailed about the failing
endpoint.
Every attempt re-runs an SSRF check on the target URL and connects to the pinned resolved IP, so a hostname that starts resolving to a private address mid-flight is dropped rather than delivered.
What to build on your side
- ·Be idempotent. A retry can arrive after your handler already
succeeded but your 2xx was lost. Key on
invoice_uuid+event, or ontxidfor address events. - ·Verify before trusting. Check the signature and the timestamp before reading the body.
- ·Answer fast. Acknowledge first, process asynchronously; a slow handler burns retry budget.
- ·Treat the webhook as a hint, not the ledger. The authoritative
state is always
GET /v1/invoices/{invoice_uuid}orGET /v1/addresses/{address_uuid}.
Late payments
Addresses stay in live detection for a default of 24 hours and are then reconciled on a cold schedule for a default of 120 days. A payment that arrives after an invoice expired can still be detected and credited inside that window, which is exactly why a completion callback may follow an expiry callback for the same invoice. Nothing is ever lost on-chain: the funds went straight to the merchant's own wallet, so a miss is a bookkeeping miss, not a money miss.
For a stuck invoice you can also force a re-check yourself with
POST /v1/invoices/{invoice_uuid}/reconcile.