# 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_received` | 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_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](https://github.com/hookdeck/webhook-samples/blob/main/providers/globalstack/latest/conversion.created.json):

```json
{
  "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`:

```bash
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:

```bash
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](/docs/cli) forwards the source to your machine:

```bash
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:

```javascript
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:

```javascript
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](/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](/docs/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](/docs/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.

### 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_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](/webhooks/guides/implement-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](https://hookdeck.com) adds deduplication, routing by category and a visible retry policy in front of your ledger. [Open a free account](https://dashboard.hookdeck.com/signup).