Webhooks · Geat.io API

Webhooks

Geat.io posts a signed JSON body to your callback_url when something happens to an invoice or an address request.

  • ·Invoices: callback_url is required at creation, so every invoice has a webhook target. See Invoices.
  • ·Address requests: callback_url is 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.

EventFired when
invoice.completedAn invoice reached its confirmation threshold and was credited.
invoice.expiredAn invoice's payment window closed.
address.paidThe first confirmed payment credited to an address request.
address.paymentEvery 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"
}
FieldTypeNotes
eventstringOne of the two invoice events above.
invoice_uuidstringOur identifier - use it against GET /v1/invoices/{invoice_uuid}.
invoice_idstringYour own reference from the create call.
statusstringcompleted or expired. Mirrors the event; the internal expired_partial is never sent.
pricestringFiat amount, 2 decimal places.
currencystringFiat symbol.
customer_emailstring or null
coinobjectsymbol, name, network. Each may be null.
gross_amountstringSum of credited transactions, 18 decimal places. "0.0" on expiry.
fee_amountstringAlways zero - the product charges credits, not payment fees. "0.0" on expiry.
net_amountstringSame as gross today. "0.0" on expiry.
timestampstringISO 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"
}
FieldTypeNotes
eventstringaddress.paid for the first credit, address.payment for every later one.
address_uuidstringUse it against GET /v1/addresses/{address_uuid}.
addressstringThe receive address, verbatim.
statusstringAlways paid on these events.
coinobjectsymbol, name, network. Each may be null.
txidstringWhich on-chain transaction this delivery is about.
gross_amountstringThis transaction's amount, 18 decimal places.
fee_amountstringAlways zero.
net_amountstringThis transaction's credited amount.
tx_countintegerRunning number of credited transactions on this address, including this one.
total_receivedstringRunning sum of credited net_amounts, 18 decimal places.
timestampstringISO 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:

HeaderValue
Content-Typeapplication/json
X-Signaturesha256=<hex digest>
X-TimestampUnix seconds, as a decimal string
User-Agentgeatio-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.

ResponseTreatment
2xxDelivered. Done.
3xxRetryable. 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, 429Retryable. 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 4xxTerminal. Marked failed immediately, no retries.
5xx or a network errorRetryable.

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 on txid for 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} or GET /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.