Guide to GlobalStack Webhooks: Features and Best Practices
GlobalStack moves money between stablecoins and local currencies through one API. You create onramps that turn fiat into crypto, offramps that pay out fiat, conversions between wallets, transfers to beneficiaries and deposits into wallets, and you verify your customers through KYC. Every one of those has a lifecycle that runs on banks and blockchains, so GlobalStack's webhooks report each step: a deposit arrives or is held for review, a conversion settles, a payout succeeds, fails or comes back, a verification is approved.
What are GlobalStack webhooks?
GlobalStack webhook endpoints are managed entirely through its API. You don't subscribe to individual events. Each endpoint subscribes to categories, such as onramp or deposit, and receives every event in them. New endpoints are created inactive, and deliver nothing until you switch them on.
Delivery runs on Svix, and GlobalStack documents the scheme itself: svix-id, svix-timestamp and svix-signature headers, an HMAC-SHA256 over the ID, timestamp and raw body, keyed with a whsec_ secret. The svix-id is the body's event_id with a msg_ prefix in place of evt_.
GlobalStack webhook features
| Feature | Details |
|---|---|
| Configuration | API only, POST /v1/notifications/webhooks |
| Subscription | By category: onramp, offramp, conversion, transfer, deposit, kyc |
| Activation | Endpoints start inactive. PATCH {"active": true} to start delivery |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | svix-signature, with svix-id and svix-timestamp |
| Secret | Per endpoint, whsec_..., shown once on create or rotate |
| Body | JSON, api_version v1 |
| Event routing | Body field event_type |
| Delivery | At least once, not in lifecycle order |
| Retries and timeout | Slow handlers are retried. No schedule or timeout published |
| Logs and replay | API endpoints for logs, queued events and replay |
| Source IPs | Not published |
Events and payloads
GlobalStack documents 27 events across its six categories:
| Event | Triggered When | Common Use Cases |
|---|---|---|
onramp.awaiting_deposit | The onramp is created and waiting for the payer's deposit | Show the payer deposit_instruction |
onramp.funds_received | The deposit arrived and was recognised | Tell the customer the money is in |
onramp.processing | The conversion is in flight | Progress display |
onramp.success | The destination crypto was delivered. Terminal | Fulfil, reconcile actual_* |
onramp.failed | Could not complete. Terminal | Read failure_reason, notify |
onramp.returned | Funds were sent back. Terminal | Refund bookkeeping |
offramp.created | An offramp is created and accepted | Track the payout |
offramp.funds_received | Funds arrived and were recognised | Progress display |
offramp.processing | The payout is in flight | Progress display |
offramp.success | The fiat payout completed. Terminal | Confirm to the customer |
offramp.failed | Could not complete. Terminal | Read failure_reason, notify |
offramp.returned | The payout bounced; a debited wallet is credited back. Terminal | Reconcile the refund |
conversion.created | A conversion is created and accepted | Track the conversion |
conversion.funds_ | Funds arrived and were recognised | Progress display |
conversion.processing | The conversion is in flight | Progress display |
conversion.success | The destination wallet was credited. Terminal | Reconcile actual_* |
conversion.failed | Could not complete (quote_expired is not a genuine failure). Terminal | Read failure_reason |
conversion.returned | Funds were sent back. Terminal | Refund bookkeeping |
transfer.processing | The payout has been submitted | Progress display |
transfer.success | The payout completed. Terminal | Confirm to the beneficiary side |
transfer.failed | Failed, cancelled or reversed. Terminal | failure_reason says which |
deposit.received | Funds seen but not final | Pending balance |
deposit.held | Credited but withheld; metadata.hold_reason says why | Compliance review |
deposit.confirmed | Settled and spendable. Terminal | Release goods or credit |
deposit.failed | Could not be credited. Terminal | Investigate |
kyc.approved | Verification passed; customer.tier is the new tier | Unlock limits |
kyc.rejected | Verification declined | Read failure_reason |
Every event shares one envelope:
| Field | Meaning |
|---|---|
event_id | evt_ id, stable across retries. Equals svix-id without its msg_ prefix |
event_type | The event name. Branch on this |
event_category | The subscription family (onramp, offramp, conversion, transfer, deposit, kyc) |
event_developer_id | Your integration's public id |
api_version | Envelope version, v1 today |
event_created_at | When the event happened. A retry keeps the original time |
event_object_id | Id of the resource (onr_…, ofr_…, con_…, tfr_…, txn_…) |
event_object_status | The resource's status at the moment of the event |
event_object_changes | { "status": [previous, new] } on a real change, null otherwise |
event_object | The REST resource body plus event-only fields |
event_object adds fields that only make sense on an event: actual_source_amount, actual_ and actual_rate record what really settled, so reconcile against those rather than the quoted rate. terminal_at marks the final state, and transaction holds IDs for joining deliveries about the same money on root_transaction_id. Deposits carry those transaction IDs at the top of event_object.
This is the conversion.created sample from the webhook samples library:
{
"event_id": "evt_1b8dbe64-1f4e-5a2c-9c31-6d9a51a0f0c2",
"event_type": "conversion.created",
"event_category": "conversion",
"event_developer_id": "int_01HXYZABC1234567890ABCDEFG",
"api_version": "v1",
"event_created_at": "2026-09-30T14:20:01.000Z",
"event_object_id": "con_01KPBH8MD6R7YDJ82T4D6M3V1T",
"event_object_status": "created",
"event_object_changes": {
"status": [
null,
"created"
]
},
"event_object": {
"id": "con_01KPBH8MD6R7YDJ82T4D6M3V1T",
"reference": "TREASURY-0031",
"status": "created",
"quote_id": "quo_01KPBK123456789ABCDEFG",
"rate_source": "quoted",
"customer": {
"id": "cust_01HXYZABC1234567890ABCDEFG",
"type": "business",
"name": "Acme Ltd",
"email": "ops@acme.example",
"tier": "tier_2",
"created_at": "2026-09-01T10:00:00.000Z",
"updated_at": "2026-09-01T10:00:00.000Z"
},
"source_wallet": {
"id": "wal_01HXYZABC1234567890ABCDEFG",
"currency": "USDC",
"label": "Treasury USDC"
},
"destination_wallet": {
"id": "wal_01HABCXYZ9876543210ZYXWVU",
"currency": "NGN",
"label": "NGN Payouts"
},
"source_amount": "500.000000",
"source_currency": "USDC",
"destination_amount": "825000.00",
"received_amount": null,
"destination_currency": "NGN",
"rate": "1650.000000",
"failure_reason": null,
"failure_reason_message": null,
"metadata": {
"payout_batch": "batch_001"
},
"created_at": "2026-09-30T14:20:01.000Z",
"updated_at": "2026-09-30T14:20:01.000Z",
"actual_source_amount": null,
"actual_destination_amount": null,
"actual_rate": null,
"terminal_at": null,
"transaction": {
"id": "txn_01KPBH8MKD6P4R8T0V2X4Z6B8D",
"parent_transaction_id": null,
"root_transaction_id": "txn_01KPBH8MKD6P4R8T0V2X4Z6B8D"
}
}
}
A few lifecycle rules shape how you handle these:
- A resource emits a given event at most once. A transfer that is cancelled and then reversed produces one
transfer.failed. - A held deposit sends
deposit.held, thendeposit.confirmedwhen it's released.metadata.hold_reasonistier_limit,payin_frozenorpayin_disabled. - A cross-currency deposit stays at
deposit.received; its outcome arrives throughconversion.*events. - A returned offramp is refunded automatically and sends no
deposit.*event.offramp.returnedis the signal. - KYC sends only final outcomes. Intermediate progress is on the verifications endpoint.
Setting up GlobalStack webhooks
Create the endpoint with your API key. The URL must be HTTPS, and unknown category names return 400:
curl -X POST https://api.globalstack.io/v1/notifications/webhooks \
-H "Authorization: Bearer $GLOBALSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/globalstack", "event_categories": ["onramp", "conversion"]}'
Copy signing_secret from the response into GLOBALSTACK_ right away. It is only ever shown in plaintext in this response, or in the response to a rotation. Then activate the endpoint:
curl -X PATCH https://api.globalstack.io/v1/notifications/webhooks/{id} \
-H "Authorization: Bearer $GLOBALSTACK_API_KEY" \
-H "Content-Type: application/json" -d '{"active": true}'
To route deliveries through Hookdeck, create a source with the GlobalStack type, paste the signing secret, and use the source URL as url when you create the endpoint. The Hookdeck CLI forwards the source to your machine:
npx hookdeck-cli listen 3000 globalstack --path /webhooks/globalstack
To rotate, call POST /v1/notifications/webhooks/{id}/rotate-secret, which returns a new signing_secret. GlobalStack notes that an endpoint can have more than one valid secret during a rotation, so svix-signature may carry several v1, entries.
Securing GlobalStack webhooks
GlobalStack's verification page gives the construction directly: signed_, keyed with the base64-decoded secret after whsec_, then base64. The function below compares that against each v1, entry in constant time and falls back to webhook-* headers when svix-id isn't present:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['svix-id'] ? 'svix' : 'webhook';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
GlobalStack's manual sample includes a replay guard that rejects timestamps more than 300 seconds old, and the Svix library does the same. Hookdeck doesn't apply a timestamp tolerance and resends the original svix-timestamp when it retries or replays, so leave that guard out, or widen it well beyond your retry window, behind Hookdeck. Deduplicating on event_id gives you replay protection either way.
Money movements need careful state handling, so the receiver only stores the event:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// ledgerInbox.record(eventId, event) stores the event once and resolves when durable.
function createGlobalStackReceiver(ledgerInbox) {
const app = express();
app.post('/webhooks/globalstack', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.GLOBALSTACK_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
try {
await ledgerInbox.record(event.event_id, event);
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200); // GlobalStack retries slow handlers, so answer straight away
});
return app;
}
When GlobalStack posts to a Hookdeck GlobalStack source, a request with a bad signature still gets 200. Hookdeck records it as failed verification and doesn't forward it.
Make GlobalStack webhooks production-ready. Hookdeck Event Gateway verifies every Svix-signed money-movement event, stores it, and lets you route deposits, payouts and KYC outcomes to separate services.
GlobalStack webhook limitations and pain points
Endpoints start switched off
The Problem: You create an endpoint and nothing ever arrives.
Why It Happens: GlobalStack creates endpoints with active: false. They deliver nothing until you PATCH them active.
Workarounds:
- Make the activation call part of the same setup script that creates the endpoint.
How Hookdeck Can Help: Once the endpoint is active, Hookdeck's request log confirms the first delivery, so you aren't left guessing between "inactive" and "broken".
The secret is shown once
The Problem: Nobody saved the signing_secret, and deliveries can't be verified.
Why It Happens: GlobalStack returns it in plaintext only in the create or rotate response.
Workarounds:
- Rotate the secret with
rotate-secretto get a new one, and store it straight away this time.
How Hookdeck Can Help: Storing the secret on a Hookdeck source keeps it in one place rather than spread across services.
Lifecycle order isn't guaranteed
The Problem: An onramp.processing arrives after onramp.success, and your UI shows the payment as in flight again.
Why It Happens: GlobalStack says events are not guaranteed to arrive in lifecycle order, and delivery is at least once.
Workarounds:
- Use
event_object_statusandevent_object_changesto move state forward, and never move a resource back from a terminal state. - Deduplicate on
event_id.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeat deliveries of the same event_id before they reach your ledger.
No published retry schedule
The Problem: You can't tell how long GlobalStack keeps retrying after an outage.
Why It Happens: The docs say slow handlers are retried but don't give a schedule or a response timeout.
Workarounds:
- Respond
2xxstraight after storing the event. - After an outage, use GlobalStack's logs and replay endpoints to recover missed events.
How Hookdeck Can Help: Hookdeck receives each event immediately and retries your service on a schedule you can see and change.
Best practices
Reconcile on actual_* amounts
Quoted rates and amounts can differ from what settled. Book actual_source_amount, actual_ and actual_rate from terminal events.
Join related events on root_transaction_id
A deposit, a conversion and a payout about the same money share a root transaction. Use it to tie them together in your ledger.
Read failure_reason before alerting
GlobalStack's event table says a conversion.failed with failure_ is not a genuine failure. Handle it apart from failures that need someone to investigate.
Deduplicate on event_id
It is stable across retries. Pair it with the approach in implementing webhook idempotency.
Conclusion
GlobalStack documents its envelope, lifecycle rules and Svix signing in detail. Activate each endpoint after creating it, and save the secret straight away because it's shown only once.
Hookdeck Event Gateway adds deduplication, routing by category and a visible retry policy in front of your ledger. Open a free account.