# Guide to Onramper Webhooks: Features and Best Practices

Onramper aggregates fiat-to-crypto on-ramps (and off-ramps) behind one widget and API: a wallet or dApp embeds Onramper, and Onramper routes each purchase to a provider such as the one shown as `gatefi` in its sample. The partner's app doesn't handle the card payment or the crypto transfer itself, so it relies on Onramper's transaction status webhooks to know when a purchase has been paid, completed or has failed.

This guide covers getting webhooks switched on through Onramper, the transaction snapshot and its six statuses, verifying `X-Onramper-Webhook-Signature`, and how to cope with what the docs leave out.

## What are Onramper webhooks?

Onramper's webhooks aren't configured in a dashboard. You send your Customer Success Manager a URL and the ApiKey it should cover, and Onramper enables webhooks for that key and gives you a secret.

Each delivery is a JSON `POST` containing the transaction's current state: amounts, currencies, the underlying on-ramp provider and its transaction ID, the wallet address, the `status` and when it last changed. There's no event name; the `status` value tells you what has happened, and `transactionType` says whether it's a `buy` or an `offramp`.

Requests carry `X-Onramper-Webhook-Signature`, a hex HMAC-SHA256 of the body keyed with the secret Onramper issued.

## Onramper webhook features

| Feature | Details |
| --- | --- |
| Configuration | Through your Customer Success Manager, per ApiKey |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-onramper-webhook-signature` |
| Signed content | The request body |
| Key | Secret issued by Onramper at registration |
| Body | JSON, a flat transaction snapshot |
| Event routing | Body field `status` |
| Direction | `transactionType`: `buy` or `offramp` |
| Delivery ID | None documented |
| Retries and timeout | Not documented |

## Events and payloads

| `status` | Meaning | Typical action |
| --- | --- | --- |
| `new` | The transaction was created; no payment yet | Record the attempt |
| `pending` | In progress and waiting on a further step | Show progress to the user |
| `paid` | Payment was made, but the transaction isn't complete | Tell the user crypto is on the way |
| `completed` | The transaction completed successfully | Confirm, credit or fulfil |
| `failed` | It failed through an error or user action | Alert or offer a retry |
| `canceled` | The user or the system cancelled it | Close the attempt |

Any other value should be logged and acknowledged. Onramper doesn't say whether every status change produces a webhook or only some, so don't build logic that assumes you'll see each step.

### Payload Structure

The snapshot includes `transactionId` (Onramper's ID), `onramp` and `onrampTransactionId` (the provider and its reference), `inAmount` and `sourceCurrency`, `outAmount` and `targetCurrency`, `paymentMethod`, `walletAddress`, `transactionHash` once the transfer is on-chain, and `partnerContext`, the value you passed when launching the widget. The property table also lists `partnerFee`, which the example doesn't include.

Since every delivery is a snapshot rather than a discrete event, deduplicate on `transactionId` plus `status` and `statusDate`.

### Published payload example

A `pending` card purchase of SOL, from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/onramper/latest/pending.json). `transactionHash` stays empty until the crypto is sent.

```json
{
  "apiKey": "pk_test_01K4MF4ZBPGDCV3GFFNA684NT0",
  "country": "us",
  "inAmount": 100,
  "onramp": "gatefi",
  "onrampTransactionId": "8bf94c80-test-aabb-851-143835984d1d",
  "outAmount": 3.83527521,
  "paymentMethod": "creditcard",
  "partnerContext": "",
  "sourceCurrency": "usd",
  "status": "pending",
  "statusDate": "2023-08-09T13:15:18.725Z",
  "targetCurrency": "sol",
  "transactionId": "01H7D547TESTV2RQJ52ZAB7WF7",
  "transactionType": "buy",
  "transactionHash": "",
  "walletAddress": "testG15oy66q7cU6aNige54PxLLEfGZvRsAADjbF7D4"
}

```

## Setting up Onramper webhooks

### Prerequisites

* An Onramper partner account and ApiKey
* A public HTTPS endpoint, or a Hookdeck source URL

### Get Webhooks Enabled and Your Secret

> Onramper turns on webhooks through your Customer Success Manager. Send them the Hookdeck URL and paste the secret key they issue.

1. Decide on the endpoint URL: your server's `/webhooks/onramper` route or the URL of an Onramper source in Hookdeck.
2. Send it, with your ApiKey, to your Onramper Customer Success Manager and ask for webhooks to be enabled.
3. Onramper issues a secret key for that registration.
4. Store it as `WEBHOOK_SECRET`, used as UTF-8 bytes without decoding.

Changing the URL later also goes through your CSM, which is a good reason to register a stable URL such as a Hookdeck source.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 onramper --path /webhooks/onramper

```

Because enabling webhooks goes through your CSM, register the Hookdeck source URL the CLI prints once, then run test purchases with a `pk_test_` key whenever you need deliveries forwarded to your machine.

## Securing Onramper webhooks

Compute HMAC-SHA256 of the body as received, using the secret, hex-encode it, and compare it with `X-Onramper-Webhook-Signature`. Onramper's TypeScript sample compares with `===`; use a constant-time comparison instead. See [Security and payload validation](https://docs.onramper.com/docs/optional-webhook-setup#security-and-payload-validation).

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-onramper-webhook-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler keeps the latest status per transaction and only fulfils once, on `completed`.

```javascript
const express = require('express');

function createOnramperApp({ seenSnapshot, transactions }) {
  const app = express();
  app.post('/webhooks/onramper', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let tx;
    try {
      tx = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!tx || typeof tx !== 'object' || Array.isArray(tx)) return res.sendStatus(400);
    if (!tx.transactionId || typeof tx.status !== 'string') return res.sendStatus(400);
    try {
      if (await seenSnapshot(`${tx.transactionId}:${tx.status}:${tx.statusDate}`)) {
        return res.sendStatus(200);
      }
      await transactions.recordStatus(tx.transactionId, tx.status, tx.statusDate, tx.partnerContext);
      if (tx.status === 'completed') {
        await transactions.fulfilOnce(tx.transactionId, {
          direction: tx.transactionType,
          amount: tx.outAmount,
          currency: tx.targetCurrency,
          hash: tx.transactionHash,
        });
      } else if (tx.status === 'failed' || tx.status === 'canceled') {
        await transactions.close(tx.transactionId, tx.status);
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Only the body is signed, with no timestamp, so a captured snapshot can be replayed. The dedup key and `fulfilOnce` stop a repeat from crediting a user twice.

Hookdeck's Onramper source type verifies `X-Onramper-Webhook-Signature` against the raw body using the secret Onramper gave you. Onramper always receives `200`; requests that fail the check are recorded as failed verification and aren't forwarded. No timestamp tolerance applies.

> **Make Onramper webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) gives Onramper a stable URL to register once, verifies every transaction update, and stores each one so you can replay it.

## Onramper webhook limitations and pain points

### No documented delivery guarantees

**The Problem:** You can't tell whether a missed status update will be resent.

**Why It Happens:** Onramper documents no timeout, expected response or retry policy.

**Workarounds:**

* Return `2xx` quickly after storing the snapshot.
* Flag transactions that stay open longer than expected and follow them up.

**How Hookdeck Can Help:** Hookdeck stores each update on arrival and retries delivery to your service, so your own outages don't depend on Onramper's undocumented behaviour.

### Not self-serve

**The Problem:** Changing the endpoint or rotating the secret needs a request to your CSM.

**Why It Happens:** Webhooks are enabled per ApiKey by Onramper's team, and no rotation process is documented.

**Workarounds:**

* Register a stable URL that won't need changing.
* Keep the secret in a secret manager so a rotation is a single update.

**How Hookdeck Can Help:** A Hookdeck source URL stays the same when your infrastructure changes.

### Snapshots without event IDs

**The Problem:** Duplicate or out-of-order snapshots move a transaction backwards or double-credit it.

**Why It Happens:** Each delivery is the current state with no delivery ID, and ordering isn't documented.

**Workarounds:**

* Deduplicate on `transactionId`, `status` and `statusDate`.
* Ignore a snapshot whose `statusDate` is older than the one you hold.

**How Hookdeck Can Help:** Hookdeck deduplication can use those body fields to discard repeats before they reach you.

### Unclear triggers

**The Problem:** A user waits on a `paid` update that never comes.

**Why It Happens:** The docs describe what each status means but not which changes send a webhook.

**Workarounds:**

* Treat intermediate statuses as optional and key important actions on `completed`, `failed` and `canceled`.

**How Hookdeck Can Help:** Hookdeck's event history shows exactly which statuses your integration receives for each transaction.

## Best practices

### Use `partnerContext` to link transactions

Pass your own order or user reference as `partnerContext` when launching Onramper, and look it up from the webhook rather than matching on wallet addresses.

### Compare signatures in constant time

Onramper's sample uses `===`. Use `crypto.timingSafeEqual` or `hmac.compare_digest` on decoded bytes.

### Keep test and live apart

The sample uses a `pk_test_` key. Check `apiKey` in the body and route test transactions away from production fulfilment.

### Acknowledge quickly and process idempotently

Store, acknowledge, then act, following our [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks), and record each snapshot key with its effect as described in [implementing webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Onramper's webhooks are a stream of transaction snapshots: one shape, a `status` that changes, and a straightforward HMAC. Getting them enabled goes through your CSM, and the docs say nothing about retries, triggers or ordering, so build around `completed`, deduplicate snapshots and reconcile open transactions.

[Hookdeck Event Gateway](https://hookdeck.com) gives you a fixed URL to hand to Onramper and a stored, verifiable record of every update.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Onramper webhooks reliably in minutes.