Gareth Wilson Gareth Wilson

Guide to GlobalStack Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI only, POST /v1/notifications/webhooks
SubscriptionBy category: onramp, offramp, conversion, transfer, deposit, kyc
ActivationEndpoints start inactive. PATCH {"active": true} to start delivery
SignatureStandard Webhooks HMAC-SHA256
Signature headersvix-signature, with svix-id and svix-timestamp
SecretPer endpoint, whsec_..., shown once on create or rotate
BodyJSON, api_version v1
Event routingBody field event_type
DeliveryAt least once, not in lifecycle order
Retries and timeoutSlow handlers are retried. No schedule or timeout published
Logs and replayAPI endpoints for logs, queued events and replay
Source IPsNot published

Events and payloads

GlobalStack documents 27 events across its six categories:

EventTriggered WhenCommon Use Cases
onramp.awaiting_depositThe onramp is created and waiting for the payer's depositShow the payer deposit_instruction
onramp.funds_receivedThe deposit arrived and was recognisedTell the customer the money is in
onramp.processingThe conversion is in flightProgress display
onramp.successThe destination crypto was delivered. TerminalFulfil, reconcile actual_*
onramp.failedCould not complete. TerminalRead failure_reason, notify
onramp.returnedFunds were sent back. TerminalRefund bookkeeping
offramp.createdAn offramp is created and acceptedTrack the payout
offramp.funds_receivedFunds arrived and were recognisedProgress display
offramp.processingThe payout is in flightProgress display
offramp.successThe fiat payout completed. TerminalConfirm to the customer
offramp.failedCould not complete. TerminalRead failure_reason, notify
offramp.returnedThe payout bounced; a debited wallet is credited back. TerminalReconcile the refund
conversion.createdA conversion is created and acceptedTrack the conversion
conversion.funds_receivedFunds arrived and were recognisedProgress display
conversion.processingThe conversion is in flightProgress display
conversion.successThe destination wallet was credited. TerminalReconcile actual_*
conversion.failedCould not complete (quote_expired is not a genuine failure). TerminalRead failure_reason
conversion.returnedFunds were sent back. TerminalRefund bookkeeping
transfer.processingThe payout has been submittedProgress display
transfer.successThe payout completed. TerminalConfirm to the beneficiary side
transfer.failedFailed, cancelled or reversed. Terminalfailure_reason says which
deposit.receivedFunds seen but not finalPending balance
deposit.heldCredited but withheld; metadata.hold_reason says whyCompliance review
deposit.confirmedSettled and spendable. TerminalRelease goods or credit
deposit.failedCould not be credited. TerminalInvestigate
kyc.approvedVerification passed; customer.tier is the new tierUnlock limits
kyc.rejectedVerification declinedRead failure_reason

Every event shares one envelope:

FieldMeaning
event_idevt_ id, stable across retries. Equals svix-id without its msg_ prefix
event_typeThe event name. Branch on this
event_categoryThe subscription family (onramp, offramp, conversion, transfer, deposit, kyc)
event_developer_idYour integration's public id
api_versionEnvelope version, v1 today
event_created_atWhen the event happened. A retry keeps the original time
event_object_idId of the resource (onr_…, ofr_…, con_…, tfr_…, txn_…)
event_object_statusThe resource's status at the moment of the event
event_object_changes{ "status": [previous, new] } on a real change, null otherwise
event_objectThe REST resource body plus event-only fields

event_object adds fields that only make sense on an event: actual_source_amount, actual_destination_amount 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, then deposit.confirmed when it's released. metadata.hold_reason is tier_limit, payin_frozen or payin_disabled.
  • A cross-currency deposit stays at deposit.received; its outcome arrives through conversion.* events.
  • A returned offramp is refunded automatically and sends no deposit.* event. offramp.returned is 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_WEBHOOK_SECRET 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_content = svix_id + "." + svix_timestamp + "." + raw_body, 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-secret to 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_status and event_object_changes to 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 2xx straight 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_destination_amount and actual_rate from terminal events.

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_reason: quote_expired 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.