# Webhooks

Openfort uses webhooks to push real-time notifications to you about your transactions. All webhooks use HTTPS and deliver a JSON payload that can be used by your application. You can use webhook feeds to:

* Grant users a game item when a transaction is confirmed
* Store all transaction events in your own database for custom reporting and retention

## Using Openfort Node SDK

Use the [Openfort SDK's](https://www.npmjs.com/package/@openfort/openfort-node) `constructWebhookEvent` method to verify an incoming webhook. Pass in the request body and the signature header. See the [full webhook example](https://github.com/openfort-xyz/openfort-node/tree/main/examples/webhook) in the Node SDK repository. You can verify a webhook using the code below:

:::code-group

```ts [Node.js]
import Openfort from '@openfort/openfort-node';

app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req: Request, _res: Response) => {
    const openfort = new Openfort(process.env.OPENFORT_SECRET_KEY)
    try {
      const event = await openfort.constructWebhookEvent(
        req.body.toString(),
        req.headers['openfort-signature']
      )
      switch (event.type) {
        case "transaction_intent.successful":
          console.log(`TransactionIntent ID: ${event.data.id}`)
          break
        case "transaction_intent.failed":
          console.log(`TransactionIntent ID: ${event.data.id}`)
          break
        case "user.created":
          console.log(`User created: ${event.data.id}`)
          break
        case "user.updated":
          console.log(`User updated: ${event.data.id}, change: ${event.data.change}`)
          break
        case "user.deleted":
          console.log(`User deleted: ${event.data.id}`)
          break
        case "account.created":
          console.log(`Account created: ${event.data.id}, address: ${event.data.address}`)
          break
        default:
          console.log(`Unhandled event type ${event.type}`);
      }
    } catch (e) {
      console.error((e as Error).message)
    }
  }
)
```

```json [Webhook Object]
{
  "data": {
    "id": "tin_c502d628-5bb3-42f2-b8f5-62ba4d71df3a",
    "createdAt": 1689869074,
    "object": "transactionIntent",
...
  },
  "type": "transaction_intent.successful",
  "date": 1689869074
}
```

:::

## Webhook object

The webhook object contains the following fields as shown in the JSON tab above.

The `type` is one of the following:

* `transaction_intent.successful`: The transaction intent has arrived on-chain and is confirmed
* `transaction_intent.failed`: The transaction intent has arrived on-chain and is reverted
* `transaction_intent.cancelled`: The transaction intent parameters were not met
* `transaction_intent.broadcast`: The transaction intent was broadcasted
* `balance.project`: The project balance dropped below your configured threshold
* `balance.contract`: A watched contract balance dropped below your configured threshold
* `balance.dev_account`: A backend wallet balance dropped below your configured threshold
* `user.created`: A new user has been created
* `user.updated`: A user has been updated (e.g. email verified, phone verified, social account linked or unlinked)
* `user.deleted`: A user has been deleted
* `account.created`: A new account (smart account, EOA, etc.) has been created for a user
* `solana_transaction.broadcast`: A Solana transaction sponsored by the project was signed and submitted to the network
* `solana_transaction.successful`: A sponsored Solana transaction was confirmed on-chain
* `solana_transaction.failed`: A sponsored Solana transaction failed on-chain or expired before confirmation
* `funding.session.updated`: A [funding](/docs/configuration/funding) session changed status (waiting for payment, processing, succeeded, bounced, or expired)
* `test`: Test topic

For transaction events, the `data` is a transaction intent object.

:::note
The `balance.*` topics only fire once you define a threshold. Set it from the dashboard on the [**Notifications → Events**](https://dashboard.openfort.io/events) tab, or via the API — see [Set a balance threshold](/docs/configuration/notifications#set-a-balance-threshold).
:::

### User and account events

#### `user.created`

Triggered when a new user signs up in your project.

```json [Webhook Object]
{
  "data": {
    "id": "usr_...",
    "email": "user@example.com",
    "name": "John Doe",
    "createdAt": 1689869074
  },
  "type": "user.created",
  "date": 1689869074
}
```

#### `user.updated`

Triggered when a user's profile is updated. The `change` field indicates what was updated. Possible values:

* `email_verified`: The user's email has been verified
* `phone_verified`: The user's phone number has been verified
* `social_data_linked`: A social account or wallet has been linked to the user
* `social_data_unlinked`: A social account or wallet has been unlinked from the user

```json [Email Verified]
{
  "data": {
    "id": "usr_...",
    "updatedAt": 1689869074,
    "change": "email_verified",
    "email": "user@example.com"
  },
  "type": "user.updated",
  "date": 1689869074
}
```

```json [Phone Verified]
{
  "data": {
    "id": "usr_...",
    "updatedAt": 1689869074,
    "change": "phone_verified",
    "phoneNumber": "+1234567890"
  },
  "type": "user.updated",
  "date": 1689869074
}
```

```json [Social Account Linked]
{
  "data": {
    "id": "usr_...",
    "updatedAt": 1689869074,
    "change": "social_data_linked",
    "linkedAccount": {
      "provider": "google",
      "accountId": "provider-account-id"
    }
  },
  "type": "user.updated",
  "date": 1689869074
}
```

```json [Social Account Unlinked]
{
  "data": {
    "id": "usr_...",
    "updatedAt": 1689869074,
    "change": "social_data_unlinked",
    "unlinkedAccount": {
      "provider": "google",
      "accountId": "provider-account-id"
    }
  },
  "type": "user.updated",
  "date": 1689869074
}
```

#### `user.deleted`

Triggered when a user is deleted from your project.

```json [Webhook Object]
{
  "data": {
    "id": "usr_...",
    "deletedAt": 1689869074
  },
  "type": "user.deleted",
  "date": 1689869074
}
```

#### `account.created`

Triggered when a new blockchain account is created for a user.

```json [Webhook Object]
{
  "data": {
    "id": "acc_...",
    "change": "account_created",
    "address": "0x1234...abcd",
    "accountType": "SMART",
    "chainId": 80002,
    "chainType": "EVM",
    "createdAt": 1689869074
  },
  "type": "account.created",
  "date": 1689869074
}
```

### Solana transaction events

Fired for transactions sponsored by your project on Solana via the [Solana Paymaster endpoints](/docs/products/infrastructure/paymaster/solana/endpoints) (`signTransaction` / `signAndSendTransaction`). Each event carries the same `solanaTransaction` object — the `status` and optional `response` fields change as the transaction moves through its lifecycle.

Common fields on `data`:

| Field | Description |
|-------|-------------|
| `id` | Solana transaction identifier (`sol_<uuid>`) — stable across all three events for the same transaction |
| `object` | Always `solanaTransaction` |
| `createdAt` | Unix timestamp (seconds) when the sponsorship was created |
| `chainId` | `1` for Solana mainnet-beta, `2` for Solana devnet |
| `sender` | The user wallet (base58) that signed the transaction |
| `method` | `signTransaction` or `signAndSendTransaction` |
| `transactionSignature` | Transaction signature (base58), or `null` if not available yet |
| `estimatedCost` | Estimated fee charged upfront, in lamports (string) |
| `response` | On-chain outcome — only present on `successful` and `failed` events |

#### `solana_transaction.broadcast`

Triggered as soon as Openfort signs the transaction and (for `signAndSendTransaction`) submits it to the Solana network. At this point the transaction has a signature but has not yet been confirmed on-chain.

```json [Webhook Object]
{
  "data": {
    "id": "sol_c502d628-5bb3-42f2-b8f5-62ba4d71df3a",
    "object": "solanaTransaction",
    "createdAt": 1689869074,
    "chainId": 1,
    "sender": "9xQeWvG816bUx9EPjHmaT23YvVM2ZWbrrpZb9PusVFin",
    "method": "signAndSendTransaction",
    "transactionSignature": "5j7s...nM2",
    "estimatedCost": "50000"
  },
  "type": "solana_transaction.broadcast",
  "date": 1689869074
}
```

#### `solana_transaction.successful`

Triggered when the transaction is confirmed (or finalized) on-chain. `response.fee` carries the actual fee paid in lamports, used to reconcile the upfront estimate.

```json [Webhook Object]
{
  "data": {
    "id": "sol_c502d628-5bb3-42f2-b8f5-62ba4d71df3a",
    "object": "solanaTransaction",
    "createdAt": 1689869074,
    "chainId": 1,
    "sender": "9xQeWvG816bUx9EPjHmaT23YvVM2ZWbrrpZb9PusVFin",
    "method": "signAndSendTransaction",
    "transactionSignature": "5j7s...nM2",
    "estimatedCost": "50000",
    "response": {
      "fee": "12345"
    }
  },
  "type": "solana_transaction.successful",
  "date": 1689869120
}
```

#### `solana_transaction.failed`

Triggered when the transaction failed on-chain, or when its blockhash expired before it could be confirmed. `response.error.reason` tells you which:

* `transaction_failed`: the transaction was processed on-chain but reverted. `response.error.raw` contains the raw Solana error.
* `expired`: the blockhash expired (~90 s on Solana) and the transaction never landed. `transactionSignature` may be `null` if it was never submitted.

In both cases the upfront invoice is voided, so you are not billed for the failed attempt.

```json [Transaction failed on-chain]
{
  "data": {
    "id": "sol_c502d628-5bb3-42f2-b8f5-62ba4d71df3a",
    "object": "solanaTransaction",
    "createdAt": 1689869074,
    "chainId": 1,
    "sender": "9xQeWvG816bUx9EPjHmaT23YvVM2ZWbrrpZb9PusVFin",
    "method": "signAndSendTransaction",
    "transactionSignature": "5j7s...nM2",
    "estimatedCost": "50000",
    "response": {
      "error": {
        "reason": "transaction_failed",
        "raw": { "InstructionError": [0, "Custom"] }
      }
    }
  },
  "type": "solana_transaction.failed",
  "date": 1689869120
}
```

```json [Transaction expired before confirmation]
{
  "data": {
    "id": "sol_c502d628-5bb3-42f2-b8f5-62ba4d71df3a",
    "object": "solanaTransaction",
    "createdAt": 1689869074,
    "chainId": 2,
    "sender": "9xQeWvG816bUx9EPjHmaT23YvVM2ZWbrrpZb9PusVFin",
    "method": "signTransaction",
    "transactionSignature": null,
    "estimatedCost": "50000",
    "response": {
      "error": {
        "reason": "expired"
      }
    }
  },
  "type": "solana_transaction.failed",
  "date": 1689869210
}
```

### Funding events

Fired when a [funding](/docs/configuration/funding) session changes status. A single event — `funding.session.updated` — carries every transition; its `data` is the full funding session, so branch on `data.status`.

#### `funding.session.updated`

```json [Webhook Object]
{
  "data": {
    "id": "fnd_...",
    "object": "fundingSession",
    "status": "succeeded",
    "target": { "chain": "eip155:8453", "currency": "0x8335...2913", "address": "0xUser..." },
    "paymentMethod": {
      "type": "evm",
      "source": { "chain": "eip155:137", "currency": "0x3c49...3359", "amount": "10000000" },
      "receiverAddress": "0x...",
      "fees": []
    },
    "externalId": "order_42",
    "createdAt": 1781250000,
    "expiresAt": 1781336400
  },
  "type": "funding.session.updated",
  "date": 1781250000
}
```

`data.status` is one of `waiting_payment`, `processing`, `succeeded`, `bounced`, or `expired`.

#### Acting on each status

What each `data.status` means, and the typical response:

| `data.status` | What it means | Typical action |
| --- | --- | --- |
| `waiting_payment` | Deposit address shown; awaiting the transfer. | Show "waiting for your transfer". |
| `processing` | Deposit detected; bridging to the destination. | Show "confirming" — don't fulfil yet. |
| `succeeded` | Funds delivered to the destination wallet. | Fulfil the order, credit the user. |
| `bounced` | Delivery failed; funds refunded on the source chain. | Notify the user; offer a retry. |
| `expired` | No deposit arrived in time. | Clear the pending intent. |

:::tip
Use `externalId` (set when you create the session) to correlate a webhook back to your own order or user record.
:::

:::warning
Webhooks are at‑least‑once. De‑duplicate on the session `id` + `status`, and treat a missing webhook as a cue to fall back to [`get`](/docs/configuration/funding/headless) — never as proof a deposit failed.
:::

### Register your development webhook endpoint

Register your publicly accessible HTTPS URL in the Openfort [dashboard](https://dashboard.openfort.io/webhooks). Then decide the type of webhook you want to receive.

:::tip
You can create a tunnel to your localhost server using a tool like [ngrok](https://ngrok.com/download). For example: https://8733-191-204-177-89.sa.ngrok.io/webhooks
:::

<div align="center">
  <img alt="Add webhook configuration in Openfort dashboard" src="https://www.openfort.io/images/blog/add_webhook_dashboard_d2bd1ec8ca.png" width="90%" height="90%" />
</div>

### Test that your webhook endpoint is working properly

:::tip
Your endpoint must return a 2xx (status code 200-299) response for the webhook to be marked as delivered. Any other statuses (including 3xx) are considered failed deliveries.
:::

Send a few test transactions to check that your webhook endpoint is receiving the events.

You can specify the number of block confirmations you want to wait before getting notified of a transaction making it on chain. The default is 0 (that is, as soon as the transaction arrives on chain).

To do so, you need to include the `confirmationBlocks` body parameter when [creating the transaction intent](https://openfort-xyz.github.io/swagger-api-doc/#/TransactionIntents/CreateTransactionIntent).

#### Verify that the webhook was sent and signed by Openfort

Every webhook Openfort delivers includes an `openfort-signature` header containing an HMAC-SHA256 hex digest of the raw request body, computed with your webhook signing key (the `whsec_...` value shown in the dashboard). If you'd rather verify the signature yourself instead of using `constructWebhookEvent` from the Node SDK, the snippet below shows a minimal Express listener that recomputes the signature and rejects any request whose signature is missing or doesn't match.

Use the raw body (not the parsed JSON) when computing the HMAC — any reserialization will change the bytes and break the comparison.

```ts [TypeScript]
import express, { type Request, type Response } from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'

const WEBHOOK_SIGNING_KEY = process.env.OPENFORT_WEBHOOK_SIGNING_KEY ?? ''
const SIGNATURE_HEADER = 'openfort-signature'

function verifySignature(body: Buffer, received: string): boolean {
  const expected = createHmac('sha256', WEBHOOK_SIGNING_KEY).update(body).digest('hex')
  const expectedBuf = Buffer.from(expected, 'utf8')
  const receivedBuf = Buffer.from(received, 'utf8')
  if (expectedBuf.length !== receivedBuf.length) return false
  return timingSafeEqual(expectedBuf, receivedBuf)
}

const app = express()

app.post(
  '/get-webhook',
  express.raw({ type: 'application/json' }),
  (req: Request, res: Response) => {
    const received = req.header(SIGNATURE_HEADER)
    if (!received) {
      console.log('Rejected: missing signature header')
      return res.status(401).send('Missing signature')
    }
    if (!verifySignature(req.body, received)) {
      console.log('Rejected: invalid signature')
      return res.status(401).send('Invalid signature')
    }

    console.log(`[${new Date().toISOString()}] Webhook received`)
    console.log(`Body: ${req.body.toString('utf8')}`)
    res.status(200).send('OK')
  },
)

const port = 8077
app.listen(port, () => {
  console.log(`Webhook listener running on http://localhost:${port}/get-webhook`)
})
```

:::warning
Always use a constant-time comparison (such as `crypto.timingSafeEqual`) when checking the signature. A regular string equality check leaks timing information that can be used to forge a valid signature.
:::

<div align="center">
  <img alt="Webhook logs showing delivery status in Openfort dashboard" src="https://www.openfort.io/images/blog/webhook_logs_78c102fac5.png" width="90%" height="90%" />
</div>
