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

# Webhooks

> Receive agent action, payout and payment webhooks for agent accounts, and verify their signatures

Grid tells your backend about agent activity with the same signed webhooks it uses for everything else. Configure your endpoint in the dashboard, then handle the events below.

## Agent events

| Event | Sent when | Payload |
| - | - | - |
| `AGENT_ACTION.PENDING_APPROVAL` | An agent's request needs the customer's approval | The full `AgentAction`, including the quote, purchase card, stored card use, payout, payout run or settlement rule it is for |
| `AGENT_ACTION.APPROVED` | An action was approved, including a stored card use Grid auto-approved because the customer chose to | The `AgentAction` |
| `AGENT_ACTION.REJECTED` | An action was rejected | The `AgentAction`, with `rejectionReason` when one was given |
| `AGENT_ACTION.FAILED` | An action was not executed, or execution failed | The `AgentAction`, with `failureReason` |
| `CLAIMABLE_PAYOUT.CLAIMED`, `.PAID`, `.EXPIRED`, `.FAILED` | A claimable payout was claimed, paid, expired or failed. `EXPIRED` and `FAILED` mean the hold on the sender's wallet was released | The `ClaimablePayout` |
| `PAYOUT_RUN.COMPLETED`, `.PARTIALLY_FAILED`, `.FAILED` | No row of a payout run is left to pay. A cancelled run sends none | The run with its row counts |
| `SETTLEMENT_RULE.EXECUTED`, `.FAILED`, `.SKIPPED` | A settlement rule's execution on an incoming payment finished | The execution |

The money itself moves with the standard `INCOMING_PAYMENT` and `OUTGOING_PAYMENT` webhooks, and agent cards send the same card status and card transaction webhooks as any card.

### Pending approval example

```json theme={null}
{
  "id": "Webhook:019542f5-b3e7-1d02-0000-000000000021",
  "type": "AGENT_ACTION.PENDING_APPROVAL",
  "timestamp": "2026-10-26T15:00:00Z",
  "data": {
    "id": "AgentAction:019542f5-b3e7-1d02-0000-000000000098",
    "agentId": "Agent:019542f5-b3e7-1d02-0000-000000000042",
    "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000010",
    "platformCustomerId": "user-a1b2c3",
    "status": "PENDING_APPROVAL",
    "type": "ISSUE_PURCHASE_CARD",
    "purchaseCard": {
      "amount": 12000,
      "spendLimit": 13000,
      "currency": "USD",
      "description": "Concert tickets from example-tickets.com",
      "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002"
    },
    "expiresAt": "2026-10-26T15:15:00Z",
    "createdAt": "2026-10-26T15:00:00Z",
    "updatedAt": "2026-10-26T15:00:00Z"
  }
}
```

### Handling agent webhooks

* Respond `200` quickly and do the work asynchronously. A webhook whose `id` you have already processed can be acknowledged and skipped.
* On `AGENT_ACTION.PENDING_APPROVAL`, notify the customer immediately. A send without `priceTolerance` expires with its quote, often within minutes.
* Don't rely on webhooks alone for the approval queue. `GET /agents/approvals` rebuilds it if your endpoint was down.
* Send a signed test event to your endpoint at any time with `POST /webhooks/test`.

All webhooks sent by the Grid API include a signature in the `X-Grid-Signature` header, which allows you to verify the authenticity of the webhook. This is critical for security, as it ensures that only legitimate webhooks from Grid are processed by your system.

## Signature Verification Process

1. **Obtain your Grid public key**
   * This is available in your [dashboard](https://app.lightspark.com) under Developers webhook settings.
   * The key is in PEM format and can be used with standard cryptographic libraries

2. **Verify incoming webhooks**
   * Extract the signature from the `X-Grid-Signature` header
   * Decode the base64 signature
   * Create a SHA-256 hash of the entire request body
   * Verify the signature using the Grid webhook public key and the hash
   * Only process the webhook if the signature verification succeeds

## Verification Examples

### Node.js Example

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

// Your Grid public key provided during integration
const GRID_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----`;

app.post('/webhooks/uma', (req, res) => {
  const signatureHeader = req.header('X-Grid-Signature');

  if (!signatureHeader) {
    return res.status(401).json({ error: 'Signature missing' });
  }

  try {
    let signature: Buffer;
    try {
      // Parse the signature as JSON. It's in the format {"v": "1", "s": "base64_signature"}
      const signatureObj = JSON.parse(signatureHeader);
      if (signatureObj.v && signatureObj.s) {
        // The signature is in the 's' field
        signature = Buffer.from(signatureObj.s, "base64");
      } else {
        throw new Error("Invalid JSON signature format");
      }
    } catch {
      // If JSON parsing fails, treat as direct base64
      signature = Buffer.from(signatureHeader, "base64");
    }

    // Create verifier with the public key and correct algorithm
    const verifier = crypto.createVerify("SHA256");
    const payload = await request.text();
    verifier.update(payload);
    verifier.end();

    // Verify the signature using the webhook public key
    const isValid = verifier.verify(
      {
        key: GRID_WEBHOOK_PUBLIC_KEY,
        format: "pem",
        type: "spki",
      },
      signature,
    );

    if (!isValid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Webhook is verified, process it based on type
    const webhookData = req.body;

    if (webhookData.type.startsWith('INCOMING_PAYMENT.')) {
      // Process incoming payment webhook
      // ...
    } else if (webhookData.type.startsWith('OUTGOING_PAYMENT.')) {
      // Process outgoing payment webhook
      // ...
    }

    // Acknowledge receipt of the webhook
    return res.status(200).json({ received: true });
  } catch (error) {
    console.error('Signature verification error:', error);
    return res.status(401).json({ error: 'Signature verification failed' });
  }
});

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});
```

### Python Example

```python theme={null}
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from flask import Flask, request, jsonify
import base64

app = Flask(__name__)

# Your Grid public key provided during integration
GRID_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----"""

# Load the public key
public_key = serialization.load_pem_public_key(
    GRID_PUBLIC_KEY.encode('utf-8')
)

@app.route('/webhooks/uma', methods=['POST'])
def handle_webhook():
    # Get signature from header
    signature = request.headers.get('X-Grid-Signature')
    if not signature:
        return jsonify({'error': 'Signature missing'}), 401

    try:
        # Get the raw request body
        request_body = request.get_data()

        # Create a SHA-256 hash of the request body
        hash_obj = hashes.Hash(hashes.SHA256())
        hash_obj.update(request_body)
        digest = hash_obj.finalize()

        # Decode the base64 signature
        signature_bytes = base64.b64decode(signature)

        # Verify the signature
        try:
            public_key.verify(
                signature_bytes,
                request_body,
                ec.ECDSA(hashes.SHA256())
            )
        except Exception as e:
            return jsonify({'error': 'Invalid signature'}), 401

        # Webhook is verified, process it based on type
        webhook_data = request.json

        if webhook_data['type'].startswith('INCOMING_PAYMENT.'):
            # Process incoming payment webhook
            # ...
            pass
        elif webhook_data['type'].startswith('OUTGOING_PAYMENT.'):
            # Process outgoing payment webhook
            # ...
            pass

        # Acknowledge receipt of the webhook
        return jsonify({'received': True}), 200
    except Exception as e:
        print(f'Signature verification error: {e}')
        return jsonify({'error': 'Signature verification failed'}), 401

if __name__ == '__main__':
    app.run(port=3000)
```

## Testing

To test your webhook implementation, you can trigger a test webhook from the Grid dashboard. This will send a test webhook to the endpoint you provided during the integration process. The test webhook will also be sent automatically when you update your platform configuration with a new webhook URL.

An example of the test webhook payload is shown below:

```json theme={null}
{
  "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007",
  "type": "TEST",
  "timestamp": "2025-08-15T14:32:00Z",
  "data": {}
}
```

You should verify the signature of the webhook using the Grid public key and the process outlined in the [Signature Verification Process](#signature-verification-process) section and then reply with a 200 OK response to acknowledge receipt of the webhook.

## Security Considerations

* **Always verify signatures**: Never process webhooks without verifying their signatures.
* **Use HTTPS**: Ensure your webhook endpoint uses HTTPS to prevent man-in-the-middle attacks.
* **Implement idempotency**: Use the `id` field to prevent processing duplicate webhooks.
* **Timeout handling**: Implement proper timeout handling and respond to webhooks promptly.

## Retry Policy

The Grid API will retry webhooks with the following policy based on the webhook type:

| Webhook Type | Retry Policy | Notes |
| - | - | - |
| `TEST` | No retries | Used for testing webhook configuration |
| `OUTGOING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) |
| `INCOMING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on: 409 (duplicate webhook) or PENDING status since it is served as an approval mechanism in-flow |
| `BULK_UPLOAD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) |
| `WALLET_OPERATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) |
| `INVITATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) |
| `CUSTOMER.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) |
| `ACCOUNT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) |
| `CARD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) |
| `CARD_TRANSACTION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) |
| `CARD_AUTHORIZATION.REQUEST` | No retries | Real-time decision request, sent only while `cardConfigs.realTimeAuthorization` is set |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.