> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lightspark.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time notifications for ramp conversions, account updates, and transaction status

Webhooks provide real-time notifications about ramp operations, allowing you to respond immediately to conversion completions, account balance changes, and transaction status updates.

## Webhook events for ramps

Grid sends webhooks for key events in the ramp lifecycle:

### Conversion events

<Tabs>
  <Tab title="OUTGOING_PAYMENT">
    Sent when a conversion (on-ramp or off-ramp) changes status. Webhook types use the format `OUTGOING_PAYMENT.<STATUS>` (e.g., `OUTGOING_PAYMENT.COMPLETED`, `OUTGOING_PAYMENT.FAILED`, `OUTGOING_PAYMENT.REFUND_COMPLETED`).

    ```json theme={null}
    {
      "id": "Webhook:019542f5-b3e7-1d02-0000-000000000030",
      "type": "OUTGOING_PAYMENT.COMPLETED",
      "timestamp": "2025-10-03T15:03:00Z",
      "data": {
        "id": "Transaction:019542f5-b3e7-1d02-0000-000000000025",
        "status": "COMPLETED",
        "type": "OUTGOING",
        "sentAmount": {
          "amount": 10000,
          "currency": { "code": "USD", "decimals": 2 }
        },
        "receivedAmount": {
          "amount": 95000,
          "currency": { "code": "BTC", "decimals": 8 }
        },
        "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001",
        "settledAt": "2025-10-03T15:02:30Z",
        "exchangeRate": 9.5,
        "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006"
      }
    }
    ```

    <Check>
      Use this webhook to update your UI, credit customer accounts, and trigger post-conversion workflows. See the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle) guide for all status transitions and refund handling.
    </Check>
  </Tab>

  <Tab title="INTERNAL_ACCOUNT.BALANCE_UPDATED">
    Sent when internal account balances change (deposits, conversions, withdrawals).

    ```json theme={null}
    {
      "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007",
      "type": "INTERNAL_ACCOUNT.BALANCE_UPDATED",
      "timestamp": "2025-10-03T15:03:00Z",
      "data": {
        "id": "InternalAccount:btc456",
        "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001",
        "type": "INTERNAL_CRYPTO",
        "status": "ACTIVE",
        "balance": {
          "amount": 5000000,
          "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 }
        },
        "fundingPaymentInstructions": [],
        "createdAt": "2025-08-01T10:00:00Z",
        "updatedAt": "2025-10-03T15:03:00Z"
      }
    }
    ```

    <Info>
      Critical for tracking crypto deposits (for off-ramps) and fiat balance changes.
    </Info>
  </Tab>

  <Tab title="INTERNAL_ACCOUNT.STATUS_UPDATED">
    Sent when the status of an internal account changes (e.g., PENDING → ACTIVE, ACTIVE → FROZEN).

    ```json theme={null}
    {
      "id": "Webhook:019542f5-b3e7-1d02-0000-000000000008",
      "type": "INTERNAL_ACCOUNT.STATUS_UPDATED",
      "timestamp": "2025-10-03T15:03:00Z",
      "data": {
        "id": "InternalAccount:btc456",
        "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001",
        "type": "INTERNAL_CRYPTO",
        "status": "FROZEN",
        "balance": {
          "amount": 5000000,
          "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 }
        },
        "fundingPaymentInstructions": [],
        "createdAt": "2025-08-01T10:00:00Z",
        "updatedAt": "2025-10-03T15:03:00Z"
      }
    }
    ```

    <Warning>
      Account status values: `PENDING` (provisioning), `ACTIVE` (ready for payments), `CLOSED` (customer-initiated), `FROZEN` (compliance/fraud hold). Frozen accounts cannot send or receive payments.
    </Warning>
  </Tab>

  <Tab title="CUSTOMER.KYC_*">
    Sent when customer KYC verification completes (required before conversions). Business customers emit the `CUSTOMER.KYB_*` siblings.

    ```json theme={null}
    {
      "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020",
      "type": "CUSTOMER.KYC_APPROVED",
      "timestamp": "2025-10-03T14:32:00Z",
      "data": {
        "id": "Customer:019542f5-b3e7-1d02-0000-000000000001",
        "platformCustomerId": "user_12345",
        "customerType": "INDIVIDUAL",
        "kycStatus": "APPROVED",
        "fullName": "Jane Doe",
        "createdAt": "2025-07-21T17:32:28Z",
        "updatedAt": "2025-10-03T14:32:00Z"
      }
    }
    ```

    <Tip>
      Enable ramp access immediately when KYC status changes to `APPROVED`.
    </Tip>
  </Tab>
</Tabs>

## Webhook configuration

Configure your webhook endpoint in the Grid dashboard or via API:

```bash theme={null}
curl -X PATCH 'https://api.lightspark.com/grid/2025-10-13/config' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "webhookEndpoint": "https://api.yourapp.com/webhooks/grid"
  }'
```

<Warning>
  Your webhook endpoint must be publicly accessible over HTTPS and respond
  within 30 seconds.
</Warning>

## Webhook verification

Always verify webhook signatures to ensure authenticity:

### Verification steps

<Steps>
  <Step title="Extract signature header">
    Get the `X-Grid-Signature` header from the webhook request.

    ```javascript theme={null}
    const signature = req.headers['x-grid-signature'];
    ```
  </Step>

  <Step title="Get public key">
    Retrieve the Grid public key from your dashboard (provided during onboarding).

    ```javascript theme={null}
    const publicKey = process.env.GRID_PUBLIC_KEY;
    ```
  </Step>

  <Step title="Verify signature">
    Use the public key to verify the signature against the raw request body.

    ```javascript theme={null}
    const crypto = require('crypto');

    function verifyWebhookSignature(payload, signature, publicKey) {
      const verify = crypto.createVerify('SHA256');
      verify.update(payload);
      verify.end();
      
      return verify.verify(publicKey, signature, 'base64');
    }

    // In your webhook handler
    const rawBody = JSON.stringify(req.body);
    const isValid = verifyWebhookSignature(rawBody, signature, publicKey);

    if (!isValid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    ```
  </Step>
</Steps>

<Note>
  The signature is created using secp256r1 (P-256) asymmetric cryptography with
  SHA-256 hashing.
</Note>

## Handling ramp webhooks

### On-ramp completion

Handle successful fiat-to-crypto conversions:

```javascript theme={null}
app.post("/webhooks/grid", async (req, res) => {
  // Verify signature
  if (!verifySignature(req.body, req.headers["x-grid-signature"])) {
    return res.status(401).end();
  }

  const { type, data } = req.body;

  if (type === "OUTGOING_PAYMENT.COMPLETED") {
    // On-ramp completed (USD → BTC)
    if (
      data.sentAmount.currency.code === "USD" &&
      data.receivedAmount.currency.code === "BTC"
    ) {
      await db.transactions.create({
        userId: data.customerId,
        type: "ON_RAMP",
        amountUsd: data.sentAmount.amount,
        amountBtc: data.receivedAmount.amount,
        rate: data.exchangeRate,
        status: "COMPLETED",
        completedAt: new Date(data.settledAt),
      });

      await sendNotification(data.customerId, {
        title: "Bitcoin purchased!",
        message: `You received ${formatBtc(data.receivedAmount.amount)} BTC`,
      });
    }
  }

  res.status(200).json({ received: true });
});
```

### Off-ramp completion

Handle successful crypto-to-fiat conversions:

```javascript theme={null}
if (type === "OUTGOING_PAYMENT.COMPLETED") {
  // Off-ramp completed (BTC → USD)
  if (
    data.sentAmount.currency.code === "BTC" &&
    data.receivedAmount.currency.code === "USD"
  ) {
    await db.transactions.create({
      userId: data.customerId,
      type: "OFF_RAMP",
      amountBtc: data.sentAmount.amount,
      amountUsd: data.receivedAmount.amount,
      rate: data.exchangeRate,
      status: "COMPLETED",
      bankAccountId: data.destination.accountId,
      completedAt: new Date(data.settledAt),
    });

    await sendNotification(data.customerId, {
      title: "Cash out completed!",
      message: `$${formatUsd(data.receivedAmount.amount)} sent to your bank account`,
    });
  }
}
```

### Balance updates

Track crypto deposits for off-ramp liquidity:

```javascript theme={null}
if (type === "INTERNAL_ACCOUNT.BALANCE_UPDATED") {
  const { data } = req.body;
  const { id: accountId, balance: newBalance } = data;

  // Crypto deposit detected
  if (
    newBalance.currency.code === "BTC" &&
    newBalance.amount > oldBalance.amount
  ) {
    const depositAmount = newBalance.amount - oldBalance.amount;

    // Record deposit
    await db.deposits.create({
      accountId,
      currency: "BTC",
      amount: depositAmount,
      newBalance: newBalance.amount,
    });

    // Check if user has pending off-ramp
    const pendingOffRamp = await db.offRamps.findPending(accountId);
    if (pendingOffRamp && newBalance.amount >= pendingOffRamp.requiredAmount) {
      // Auto-execute pending off-ramp
      await executeOffRamp(pendingOffRamp.id);
    }
  }
}
```

## Best practices

<AccordionGroup>
  <Accordion title="Process webhooks idempotently">
    Handle duplicate webhooks gracefully using the envelope `id`:

    ```javascript theme={null}
    const { id: webhookId } = req.body;

    // Check if already processed
    const existing = await db.webhooks.findUnique({ where: { webhookId } });
    if (existing) {
      return res.status(200).json({ received: true });
    }

    // Process webhook
    await processRampWebhook(req.body);

    // Record webhook ID
    await db.webhooks.create({
      data: { webhookId, processedAt: new Date() }
    });
    ```
  </Accordion>

  <Accordion title="Use transaction IDs for deduplication">
    The transaction lives on `data` and its `id` uniquely identifies the conversion:

    ```javascript theme={null}
    const { data: transaction } = req.body;

    // Upsert transaction (handles duplicates)
    await db.transactions.upsert({
      where: { gridTransactionId: transaction.id },
      update: { status: transaction.status },
      create: {
        gridTransactionId: transaction.id,
        customerId: transaction.customerId,
        status: transaction.status,
        // ... other fields
      },
    });
    ```
  </Accordion>

  <Accordion title="Implement retry logic">
    Grid retries failed webhooks with exponential backoff. Ensure your endpoint can handle retries:

    ```javascript theme={null}
    app.post('/webhooks/grid', async (req, res) => {
      try {
        await processWebhook(req.body);
        res.status(200).json({ received: true });
      } catch (error) {
        console.error('Webhook processing error:', error);
        
        // Return 5xx for retryable errors
        if (error.retryable) {
          res.status(503).json({ error: 'Temporary failure' });
        } else {
          // Return 200 for non-retryable to prevent retries
          res.status(200).json({ error: error.message });
        }
      }
    });
    ```
  </Accordion>

  <Accordion title="Monitor webhook health">
    Track webhook delivery and processing:

    ```javascript theme={null}
    // Log webhook metrics
    await metrics.increment('webhooks.received', {
      type: req.body.type,
      status: req.body.transaction?.status,
    });

    // Track processing time
    const start = Date.now();
    await processWebhook(req.body);
    const duration = Date.now() - start;

    await metrics.histogram('webhooks.processing_time', duration, {
      type: req.body.type,
    });
    ```
  </Accordion>
</AccordionGroup>

## Testing webhooks

Test webhook handling using the test endpoint:

```bash theme={null}
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/webhooks/test' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
```

This sends a test webhook to your configured endpoint:

```json theme={null}
{
  "test": true,
  "timestamp": "2025-10-03T14:32:00Z",
  "id": "Webhook:test001",
  "type": "TEST"
}
```

<Check>
  Verify your endpoint receives the test webhook and responds with a 200 status
  code.
</Check>

## Next steps

* [Sandbox Testing](/ramps/platform-tools/sandbox-testing) - Test ramp flows end-to-end
* [Platform Configuration](/ramps/onboarding/platform-configuration) - Configure webhook endpoint
* [Fiat-to-Crypto Conversion](/ramps/conversion-flows/fiat-crypto-conversion) - Implement conversion flows
* [API Reference](/api-reference) - Complete webhook documentation
