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

# Webhooks API Guide

> Integration guide for Webhooks

Listen to real-time events in your gigstack account. The Webhooks API allows you to configure endpoints that receive event notifications when specific actions occur in your system.

## Overview

Webhooks enable you to build event-driven integrations by receiving automatic HTTP POST notifications when events occur in your gigstack account. Configure which events to listen for and where to receive them.

## Key Features

* **Real-time Notifications** - Instant event delivery to your endpoints
* **Event Filtering** - Subscribe only to events you need
* **Status Control** - Enable/disable webhooks without deletion
* **Multi-event Support** - Single webhook can listen to multiple event types
* **Automatic Retries** - Failed deliveries of resource events are retried with backoff (see [Delivery Behavior](#delivery-behavior))

## Endpoints

### List Webhooks

```http theme={null}
GET /webhooks
```

Retrieve all configured webhooks for your team.

**Query Parameters:**

* `limit` (integer, 1-100) - Number of results to return (default: 10)
* `status` (string) - Filter by status: `active` or `inactive`
* `team` (string) - gigstack Connect: Target team ID

**Example Request:**

```bash theme={null}
curl -X GET "https://api.gigstack.io/v2/webhooks?limit=20" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Example Response:**

```json theme={null}
{
    "success": true,
    "message": "Webhooks retrieved successfully",
    "data": [
        {
            "id": "wh_dyS2ZVTj",
            "url": "https://webhook.site/7cd05529-40fe-4c98-88e5-761de9c5feb1",
            "events": [
                "payment.created",
                "payment.succeeded",
                "invoice.created"
            ],
            "status": "active",
            "description": "Production payment notifications",
            "owner": "8UWdgXELUhf022vuoq249mtGytG2",
            "created_at": 1709090576567
        }
    ],
    "timestamp": 1709090576567
}
```

### Get Webhook

```http theme={null}
GET /webhooks/{id}
```

Retrieve details of a specific webhook.

**Example Request:**

```bash theme={null}
curl -X GET https://api.gigstack.io/v2/webhooks/wh_dyS2ZVTj \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Example Response:**

```json theme={null}
{
    "success": true,
    "message": "Webhook retrieved successfully",
    "data": {
        "id": "wh_dyS2ZVTj",
        "url": "https://webhook.site/7cd05529-40fe-4c98-88e5-761de9c5feb1",
        "events": [
            "payment.created",
            "payment.succeeded",
            "invoice.created"
        ],
        "status": "active",
        "description": "Production payment notifications",
        "owner": "8UWdgXELUhf022vuoq249mtGytG2",
        "created_at": 1709090576567
    },
    "timestamp": 1709090576567
}
```

### Create Webhook

```http theme={null}
POST /webhooks
```

Create a new webhook endpoint to receive event notifications.

**Request Body:**

```json theme={null}
{
    "url": "https://your-domain.com/webhooks/gigstack",
    "events": [
        "payment.created",
        "payment.succeeded",
        "invoice.created"
    ],
    "description": "Production webhook for payment events",
    "status": "active"
}
```

**Required Fields:**

* `url` (string) - Endpoint URL that receives the events. Any valid URL is accepted; use HTTPS in production
* `events` (array) - List of event types to subscribe to (see Available Events below)

**Optional Fields:**

* `description` (string) - Human-readable description of the webhook
* `status` (string) - `active` or `inactive` (default: `active`)

**Example Request:**

```bash theme={null}
curl -X POST https://api.gigstack.io/v2/webhooks \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-domain.com/webhooks/gigstack",
    "events": ["payment.created", "payment.succeeded"],
    "description": "Payment notifications webhook"
  }'
```

**Example Response:**

```json theme={null}
{
    "success": true,
    "message": "Webhook created successfully. Save the secret — it will not be shown again.",
    "data": {
        "id": "wh_dyS2ZVTj",
        "url": "https://your-domain.com/webhooks/gigstack",
        "events": ["payment.created", "payment.succeeded"],
        "status": "active",
        "description": "Payment notifications webhook",
        "owner": "8UWdgXELUhf022vuoq249mtGytG2",
        "created_at": 1709090576567,
        "secret": "3f9a1c0e7b2d4f6a8c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f4a6c8e0b1d3f5a"
    },
    "timestamp": 1709090576600
}
```

> **Store `secret` now.** It is returned only in this response — `GET`, `PUT` and list calls never include it, and it cannot be revealed or rotated later. It signs `sat.invoice.synced` and `invoice_batch.completed` deliveries only (see [Verifying Signatures](#verifying-signatures)); resource events such as `payment.succeeded` are not signed with it. If you lose it, delete the webhook and create a new one.

### Update Webhook

```http theme={null}
PUT /webhooks/{id}
```

Update an existing webhook's configuration.

**Request Body:**

All fields are optional. Only include fields you want to update.

```json theme={null}
{
    "url": "https://new-domain.com/webhooks/gigstack",
    "events": ["payment.created", "payment.succeeded", "invoice.created"],
    "description": "Updated webhook description",
    "status": "inactive"
}
```

**Example Request:**

```bash theme={null}
curl -X PUT https://api.gigstack.io/v2/webhooks/wh_dyS2ZVTj \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "inactive",
    "description": "Temporarily disabled for maintenance"
  }'
```

### Delete Webhook

```http theme={null}
DELETE /webhooks/{id}
```

Permanently delete a webhook endpoint.

**Example Request:**

```bash theme={null}
curl -X DELETE https://api.gigstack.io/v2/webhooks/wh_dyS2ZVTj \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Example Response:**

```json theme={null}
{
    "success": true,
    "message": "Webhook deleted successfully"
}
```

## Available Events

A webhook can subscribe to any of these event types:

| Event | Meaning |
| - | - |
| `payment.created` | New payment request created |
| `payment.updated` | Payment information updated |
| `payment.succeeded` | Payment successfully processed |
| `payment.canceled` | Payment canceled |
| `payment.deleted` | Payment deleted |
| `payment.upcoming_due_date` | Payment due date approaching |
| `invoice.created` | New invoice created |
| `invoice.canceled` | Invoice canceled with SAT |
| `invoice.failed` | Invoice stamping failed |
| `invoice_batch.completed` | Every invoice of a [batch](/guides/invoice-batches) (`POST /invoices/income/batch`) has a final status |
| `receipt.created` | New receipt generated |
| `receipt.updated` | Receipt information updated |
| `receipt.completed` | Receipt stamped successfully |
| `receipt.deleted` | Receipt deleted |
| `customer.created` | New customer/client created |
| `customer.updated` | Customer information updated |
| `customer.deleted` | Customer deleted |
| `service.created` | New service added to catalog |
| `service.updated` | Service information updated |
| `service.deleted` | Service removed from catalog |
| `sat.invoice.synced` | The XML of an invoice downloaded from the SAT ([Descarga Masiva](/guides/descarga-masiva)) was fetched and stored |

> **Delivery families.**
>
> * **Resource events** (`payment.*`, `invoice.*`, `receipt.*`, `customer.*`, `service.*`) use the body for the webhook's [payload version](#payload-versions). They are retried and are not signed.
> * **`sat.invoice.synced`** always uses its [own body](#sat-sync-event-body). It is signed with the webhook's `secret` and is sent only once.
> * **`invoice_batch.completed`** always uses its [own body](#invoice-batch-event-body), and is delivered like `sat.invoice.synced`: signed and sent only once. Despite the prefix, it is not an `invoice.*` resource event.

## Webhook Structure

### Webhook Object

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique webhook identifier (prefix: `wh_`) |
| `url` | string | Endpoint URL that receives deliveries |
| `events` | array | List of subscribed event types |
| `status` | string | `active` or `inactive`. Only active webhooks receive deliveries |
| `description` | string | Optional description |
| `owner` | string | User ID who created the webhook |
| `created_at` | integer | Unix timestamp (milliseconds) of creation |
| `secret` | string | Signs `sat.invoice.synced` and `invoice_batch.completed` deliveries. **Returned only by `POST /webhooks`**, never again |

## Webhook Payload

Each delivery is an HTTP `POST` to your URL with `Content-Type: application/json`. Respond with any `2xx` status within 10 seconds.

### Payload versions

Each webhook sends resource events in one of two body formats:

| Version | Which webhooks | Body |
| - | - | - |
| `v1` | Created with `POST /webhooks`, unless your team's default is `v2` | [v1 body](#v1-body) |
| `v2` | Created or last saved in the gigstack dashboard, or on a team whose default is `v2` | [v2 body](#v2-body) |

The webhook object returned by the API does not include its version, but you can tell the two apart by shape:

* A `v2` body has `type` and `data.object`.
* A `v1` body has `event`, `team` and `webhook`.

Saving a webhook in the dashboard switches it to `v2`.

### v1 body

```json theme={null}
{
    "event": "payment.succeeded",
    "team": "team_1234567890",
    "webhook": "wh_dyS2ZVTj",
    "livemode": true,
    "data": {
        "...": "the resource as stored by gigstack when the event happened"
    }
}
```

| Field | Type | Description |
| - | - | - |
| `event` | string | Event type |
| `team` | string | Team that owns the webhook |
| `webhook` | string | ID of the webhook receiving this delivery |
| `livemode` | boolean | `false` for test-mode resources |
| `data` | object | The resource at the moment of the event, in gigstack's internal (camelCase) format. `metadata` values are sent as strings |
| `connectedTeam` | string | Only on deliveries to a [gigstack Connect](/guides/gigstack-connect) master team: the connected team the event came from |
| `connectedTeamMetadata` | object | Only with `connectedTeam`, when that team has metadata. Values are strings |

A `v1` body has no unique delivery id. **Do not permanently de-duplicate by `event`
plus `data.id`:** two legitimate `customer.updated` events for one customer have the
same pair. Store each received notification and make processing idempotent by reading
the current resource and upserting its state in your application. Include the team
and mode in your local resource key. For irreversible effects, reconcile the business
operation rather than treating a notification as authority to repeat it.

Prefer `v2` when you need a stable delivery ID. Saving a webhook in the dashboard
switches it to `v2`; update your parser before doing so. A duplicate v2 delivery can
contain a newer resource state, so use it as a signal to reconcile current state,
not as a permanent historical snapshot.

Teams on the legacy `v1.1` default receive the same object wrapped as `{ "payload": { … } }`. For `invoice.created`, their `data` also includes a `customer` object with the full address, and `date` is shifted by −6 hours.

### v2 body

```json theme={null}
{
    "id": "log_4GqT7mZx9LpR2vWc8NdK--wh_dyS2ZVTj",
    "type": "payment.succeeded",
    "created": 1767225600000,
    "livemode": true,
    "data": {
        "object": {
            "...": "the resource in snake_case, as the API returns it"
        }
    }
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique per event and webhook. Stays the same across every retry and resend of that delivery, so use it to de-duplicate |
| `type` | string | Event type |
| `created` | integer | When the delivery was queued, Unix epoch **milliseconds** |
| `livemode` | boolean | `false` for test-mode resources |
| `data.object` | object | The resource in the same format the API returns. It is read **when the delivery is sent**, so a retried delivery can show a newer state than the event. For `*.deleted` events it is the last known state |

### Headers

| Header | Sent on |
| - | - |
| `Content-Type` | Every delivery: `application/json` |
| Custom headers | Resource events, when you add headers to the webhook in the gigstack dashboard (for example `Authorization`) |
| `X-Gigstack-Event` | `sat.invoice.synced` and `invoice_batch.completed` only: the event type |
| `X-Gigstack-Signature` | `sat.invoice.synced` and `invoice_batch.completed` only: `sha256=` + hex HMAC-SHA256 of the **raw body**, keyed with the webhook's `secret` |

Resource events are not signed. To authenticate them:

1. Add a secret header to the webhook in the dashboard, for example `Authorization: Bearer <random token>`.
2. Reject any request that doesn't carry it.

Custom headers can't be set through the API.

Webhooks created before signing was introduced have no `secret`, and their `sat.invoice.synced` and `invoice_batch.completed` deliveries have no `X-Gigstack-Signature` header. Recreate them to get one.

### SAT sync event body

`sat.invoice.synced` is sent by the SAT download service. Its body is the same regardless of the webhook's payload version:

```json theme={null}
{
    "id": "evt_4f1c9a7e2b3d5c6a",
    "event": "sat.invoice.synced",
    "created_at": 1767225600,
    "data": {
        "uuid": "9D9B0E5B-0341-4C2B-8F3A-6E1D2C4B5A70",
        "direction": "received",
        "resource_status": "ready",
        "issuer": { "rfc": "EKU9003173C9", "name": "ESCUELA KEMPER URGATE" },
        "receiver": { "rfc": "MEE200101ABC", "name": "MI EMPRESA EJEMPLO" },
        "total": 1160,
        "currency": "MXN",
        "issue_date": "2026-01-15T10:30:00",
        "invoice_type": "I",
        "status": "Vigente",
        "team": "team_1234567890",
        "credit_charged": true
    }
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique event id: `evt_` + 16 hex characters. Use it to de-duplicate |
| `event` | string | Always `sat.invoice.synced` |
| `created_at` | integer | When the event was dispatched, Unix epoch **seconds** |
| `data` | object | The synced CFDI, described below |

Compared with resource events, the type is in `event`, the time is `created_at` in **seconds**, and there is no `livemode`.

#### `data` fields

| Field | Type | Description |
| - | - | - |
| `uuid` | string | Folio fiscal (UUID) of the CFDI |
| `direction` | string | `issued` or `received` |
| `resource_status` | string | Always `ready` — the XML is stored |
| `issuer` | object | Issuer of the CFDI |
| `receiver` | object | Receiver of the CFDI |
| `total` | number | CFDI total |
| `currency` | string | CFDI currency |
| `issue_date` | string | CFDI issue date |
| `invoice_type` | string | `I`, `E`, `P`, `N` or `T` |
| `status` | string | SAT status, e.g. `Vigente` |
| `team` | string | Team that owns the invoice |
| `credit_charged` | boolean | Whether a Descarga Masiva credit was charged for this XML |
| `retried` | boolean | Present and `true` when triggered by `POST /invoices/sat/{uuid}/retry-xml` |

### Invoice batch event body

`invoice_batch.completed` is sent once, when every accepted invoice of an [income invoice batch](/guides/invoice-batches) has a final status. Like the SAT event, its body is the same regardless of the webhook's payload version, the type is in `event` and `created_at` is in **seconds**:

```json theme={null}
{
    "id": "evt_9a2b7c4d1e6f3a8b",
    "event": "invoice_batch.completed",
    "created_at": 1790784000,
    "data": {
        "id": "ibatch_5d41402abc4b2a76b9719d911017c592",
        "livemode": true,
        "total": 250,
        "accepted": 248,
        "rejected": 2,
        "counts": { "queued": 0, "stamped": 245, "failed": 2, "duplicate": 1, "needs_review": 0 },
        "result": "partially_completed"
    }
}
```

| Field | Type | Description |
| - | - | - |
| `data.id` | string | Batch id. Read the details with `GET /invoices/income/batch/{id}` |
| `data.livemode` | boolean | Mode of the credential that created the batch |
| `data.total` | integer | Invoices in the request |
| `data.accepted` | integer | Invoices that passed validation and were processed |
| `data.rejected` | integer | **Number** of invoices refused by validation. On the batch object, `rejected` is the list |
| `data.counts` | object | Accepted invoices by final status: `stamped`, `failed`, `duplicate`, `needs_review` (`queued` is `0`) |
| `data.result` | string | `completed`, `partially_completed` or `failed`. See [Batch result](/guides/invoice-batches#batch-result) |

## Delivery Behavior

Deliveries go to every **active** webhook subscribed to the event. Order across events is not guaranteed, and the same event can arrive more than once.

**Resource events**

* Each attempt times out after **10 seconds**.
* A `2xx` response counts as delivered.
* `408`, `429`, `5xx`, timeouts and network errors are retried with exponential backoff, up to 16 attempts in total.
* Any other `4xx` is final and is not retried, so don't return a `4xx` for a failure you want redelivered.
* Webhooks are never disabled automatically because of failed deliveries.

**`sat.invoice.synced` and `invoice_batch.completed`**

* One attempt with a 10-second timeout. It is **never retried**, and your response is ignored.

A delivery can still be missed, so treat webhooks as notifications and reconcile periodically against the API, for example with `GET /payments`, `GET /invoices/sat` for SAT invoices, or `GET /invoices/income/batch/{id}` for a batch.

## Verifying Signatures

Only `sat.invoice.synced` and `invoice_batch.completed` deliveries are signed. To authenticate resource events, see [Headers](#headers). Compute the HMAC over the **raw request bytes**, before any JSON parsing, and compare it in constant time. The key is the `secret` string exactly as returned when you created the webhook.

### Node.js / Express

This example is a **receiver for signed SAT and invoice-batch events only**. It
verifies the original bytes and commits the event to PostgreSQL before replying.
It does not import an invoice or complete your business workflow in the request.
Use a separate route with a configured secret header for unsigned resource events;
do not disable signature verification to mix the two families.

In your own integration project, install `express` and `pg`. Set `DATABASE_URL` to
your database, `GIGSTACK_WEBHOOK_ID` to this webhook's ID and
`GIGSTACK_WEBHOOK_SECRET` to its creation secret. Keep them in your local secret
configuration. Use your database provider's required TLS settings.

Create this inbox table in that database:

```sql theme={null}
CREATE TABLE gigstack_webhook_inbox (
    endpoint_id text NOT NULL,
    event_id text NOT NULL,
    event_type text NOT NULL,
    payload jsonb NOT NULL,
    received_at timestamptz NOT NULL DEFAULT now(),
    processed_at timestamptz,
    attempts integer NOT NULL DEFAULT 0,
    last_error text,
    PRIMARY KEY (endpoint_id, event_id)
);
```

Save this as `receiver.cjs`. The insert runs as one committed PostgreSQL statement;
a duplicate delivery returns success without creating another work item.

```javascript theme={null}
const crypto = require('node:crypto')
const express = require('express')
const { Pool } = require('pg')

for (const name of ['DATABASE_URL', 'GIGSTACK_WEBHOOK_ID', 'GIGSTACK_WEBHOOK_SECRET']) {
    if (!process.env[name]) throw new Error(`Missing ${name}`)
}
const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    connectionTimeoutMillis: 2000,
    query_timeout: 3000,
})
const endpointId = process.env.GIGSTACK_WEBHOOK_ID
const secret = process.env.GIGSTACK_WEBHOOK_SECRET
const app = express()

// Register raw-body handling BEFORE any express.json middleware on this route.
app.post('/webhooks/gigstack/signed', express.raw({ type: 'application/json', limit: '2mb' }), async (req, res) => {
    if (!Buffer.isBuffer(req.body)) return res.status(415).end()
    const received = Buffer.from(req.get('X-Gigstack-Signature') || '', 'utf8')
    const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(req.body).digest('hex'))
    if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
        return res.status(401).end()
    }

    let event
    try {
        event = JSON.parse(req.body.toString('utf8'))
    } catch {
        return res.status(400).end()
    }
    if (!event || typeof event.id !== 'string' || !event.id ||
        !['sat.invoice.synced', 'invoice_batch.completed'].includes(event.event)) {
        return res.status(400).end()
    }

    try {
        await pool.query(
            `INSERT INTO gigstack_webhook_inbox (endpoint_id, event_id, event_type, payload)
             VALUES ($1, $2, $3, $4::jsonb)
             ON CONFLICT (endpoint_id, event_id) DO NOTHING`,
            [endpointId, event.id, event.event, JSON.stringify(event)],
        )
        return res.status(200).end()
    } catch {
        // No payloads or credentials in logs. Alert on this failure.
        console.error('gigstack webhook inbox write failed')
        return res.status(503).end()
    }
})

app.listen(Number(process.env.PORT || 3000))
```

Start it with `node receiver.cjs` and register its HTTPS route. This is a complete
intake example; **you must implement the worker and reconciliation** for your product:

1. Claim unprocessed rows durably, with a database transaction or a queue lease so
   concurrent workers do not process the same row at once.
2. For `sat.invoice.synced`, read the SAT invoice using `payload.data.uuid`. For
   `invoice_batch.completed`, retrieve the batch using `payload.data.id` and page
   through its items. Use credentials belonging to the intended team.
3. Upsert your own records using stable business IDs. Commit your changes and mark
   `processed_at` only after success. If your effect is in another system, use that
   system's idempotency mechanism and reconcile uncertain outcomes.
4. On failure, retain the row, record a redacted error, and retry it with a bounded
   policy. A restart must resume unfinished rows; an in-memory set cannot do this.
5. Poll the relevant API periodically to discover missed notifications. Signed
   events are sent once: a `503` from this receiver does **not** cause gigstack to
   retry them. Alert on inbox failures and reconcile the gap.

Test intake separately from your worker: send the same correctly signed fixture twice
and expect one row; alter its body without recomputing the signature and expect `401`;
stop the database and expect no `200`; restart the worker and confirm unfinished rows
are processed. These are integration checks to run in your own environment, not claims
that this PostgreSQL example was exercised against a live webhook in the docs audit.

### Python / Flask

This is a **signature-verification helper**, not a complete receiver. Call it on
`request.get_data()` before parsing JSON, then persist/enqueue the verified event
before returning `2xx`, following the inbox requirements above.

```python theme={null}
import hashlib
import hmac

def valid_gigstack_signature(raw_body: bytes, received: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(received.encode('utf-8'), expected.encode('utf-8'))
```

### PHP

This helper performs signature verification only. Read the raw request bytes with
`file_get_contents('php://input')`, pass the signature header and stored secret,
and persist/enqueue the verified event before acknowledging it.

```php theme={null}
<?php
function validGigstackSignature(string $rawBody, string $received, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $received);
}
```

### Resource-event authentication and processing

Resource events have no `X-Gigstack-Signature`. Configure a secret custom header in
the dashboard and compare it in constant time on a separate resource-event route.
The header cannot currently be configured through this API. Keep that route closed
until the header is configured, and do not accept unsigned requests on the signed route.

For a v2 resource event, persist the delivery with its `id` before acknowledgment,
then reconcile `data.object` with the corresponding GET endpoint. For v1/v1.1,
retain each notification with your own inbox ID; the resource identity is not a
unique delivery ID. Handle deletion notifications using the last known state and
an idempotent local tombstone. Keep the two payload versions' field names and
millisecond timestamps distinct from the signed events' second-based timestamps.

## Testing Webhooks

### Using webhook.site

For development and testing, use [webhook.site](https://webhook.site) to inspect webhook payloads:

```bash theme={null}
curl -X POST https://api.gigstack.io/v2/webhooks \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://webhook.site/YOUR-UNIQUE-URL",
    "events": ["sat.invoice.synced"],
    "description": "Testing webhook"
  }'
```

### Test events from the dashboard

The **Send test event** button in the gigstack dashboard posts a sample directly from your browser:

* **Body:** your webhook's format, plus `"test": true`. A `v1` sample leaves out `team`, `webhook` and `livemode`.
* **Headers:** `X-Gigstack-Test: true`. Your custom headers are not included.
* **CORS:** your endpoint must allow cross-origin requests for the dashboard to show its response.

Use it to check that your endpoint is reachable. Build your parser from [Webhook Payload](#webhook-payload), not from a test event.

### Local Testing with ngrok

1. Start your local server:

```bash theme={null}
node receiver.cjs  # The signed-event receiver above, on port 3000
```

2. Create an ngrok tunnel:

```bash theme={null}
ngrok http 3000
```

3. Use the ngrok HTTPS URL in your webhook:

```bash theme={null}
curl -X POST https://api.gigstack.io/v2/webhooks \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/webhooks/gigstack/signed",
    "events": ["sat.invoice.synced"],
    "description": "Local development webhook"
  }'
```

## Best Practices

1. **Subscribe to specific events** - Only listen to events you need to reduce noise
2. **Use inactive status for testing** - Create webhooks with `status: "inactive"` to test configuration before enabling
3. **Plan for missed and duplicate deliveries** - Resource events are retried but can still arrive more than once or not at all, and `sat.invoice.synced` and `invoice_batch.completed` are never retried; de-duplicate, and reconcile periodically against the API
4. **Log webhook events** - Keep audit logs of received webhooks for debugging
5. **Monitor webhook performance** - Track delivery success rates and response times
6. **Version your webhook endpoints** - Use versioned URLs (e.g., `/webhooks/v1/gigstack`) for easier updates
7. **Handle all event types** - Include a default case for unknown event types to future-proof your integration

## Related Resources

* [Payments API](/guides/payments) - Process payments that trigger webhook events
* [Invoices API](/guides/invoices) - Create invoices that generate webhook events
* [Receipts API](/guides/receipts) - Manage receipts with webhook notifications
* [Clients API](/guides/clients) - Customer management with webhook events

## Error Handling

Errors use the standard error envelope.

### Validation Error (`400`)

Returned when the body is invalid: a malformed `url`, an unknown event type, or a missing required field. `details` lists each problem as `field: problem`.

```json theme={null}
{
    "success": false,
    "error": {
        "code": "validation_failed",
        "message": "Invalid webhook data",
        "details": ["events: Missing required field"]
    },
    "timestamp": 1709090576567
}
```

### Webhook Not Found (`404`)

```json theme={null}
{
    "success": false,
    "error": {
        "code": "resource_not_found",
        "message": "Webhook not found"
    },
    "timestamp": 1709090576567
}
```

***

For additional assistance, contact [support@gigstack.io](mailto:support@gigstack.io)


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