> For the complete documentation index, see [llms.txt](https://docs.sprinto.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.sprinto.com/ingestion/webhooks/ingestion-webhooks-technical-guide.md).

# Ingestion Webhooks: Technical Guide

This guide is for the people who build the sender and the ingestion plan. It covers how Sprinto processes a webhook request, where the request body appears in your plan, authentication, duplicates, limits and troubleshooting.

To set up a webhook in Sprinto, see [Create and Manage Webhooks](https://app.gitbook.com/o/3Y6icP5z46IeflA3QtMY/sites/site_N29Ru/s/Esyn5VMU6e0OyGjRtKgx/~/edit/~/changes/624/ingestion/webhooks/create-and-manage-webhooks).

### How an event is processed

Sprinto checks each request to the webhook URL in this order:

1. **Size.** A body larger than the size limit is rejected with `413`. See Limits.
2. **Webhook lookup.** The webhook must exist and be active. If not, Sprinto returns `404`.
3. **Rate limit.** Requests over **Rate Limit Per Minute** are rejected with `429`.
4. **Authentication.** A request that fails the webhook's auth check is rejected with `401`.
5. **Duplicate check.** An event Sprinto has already received returns `200` with `{"status": "duplicate"}` and does not run the plan.
6. **Event recorded.** Sprinto saves the event and returns `202` with `{"status": "accepted", "eventPk": "..."}`.

After the `202`, Sprinto processes the event in the background:

7. **Filter.** If the webhook has a filter expression and it evaluates to false, the event is marked **filtered** and the plan does not run.
8. **Plan run.** Sprinto starts a run of the attached plan with the event's body.

> **A `202` confirms receipt, not storage.** An event's status becomes **completed** when it is handed to the plan engine. Whether its records were stored depends on the plan run. Check the plan run linked from the event.

### Where the payload appears in your plan

The JSON body of the request is available in the plan as:

```
params.webhookPayload
```

* Only the body is passed to the plan. Request headers and query-string values are not available to plan steps.
* There are no other webhook variables such as `trigger`, `event` or `body`. Referring to one returns nothing, and it does not cause an error.
* Values from a Runtime Params Mapping are available as extra parameters next to `params.webhookPayload`.

### Write a plan for webhook data

A webhook plan usually has four steps:

1. A `transform` step that reads `params.webhookPayload` and returns a list of records.
2. A `mapEntityType` step that turns each record into the fields of your entity type.
3. An `insertCustomEntities` step that stores the records.
4. An `evaluateEntities` step that runs checks on the records from this run.

#### Example

The sender posts one server per request:

```json
{
  "identifier": "srv-0142",
  "name": "web-prod-01",
  "os_version": "Ubuntu 24.04",
  "disk_encrypted": true,
  "last_patch_date": "2026-09-09T00:00:00Z"
}
```

This plan stores it as a `Server` entity:

```yaml
version: "1.0"
name: "server-inventory-webhook"
description: "Ingest Server records pushed by webhook"
steps:
  - name: "extractPayload"
    type: "transform"
    expression: |-
      [$$.params.webhookPayload]
  - name: "mapServers"
    type: "mapEntityType"
    input: "steps.extractPayload.output"
    entityType: "Server"
    expression: |-
      $map($, function($d) {
        {
          "identifier": $d.identifier,
          "name": $d.name,
          "os_version": $d.os_version,
          "disk_encrypted": $d.disk_encrypted,
          "last_patch_date": $d.last_patch_date
        }
      })
  - name: "insertServers"
    type: "insertCustomEntities"
    input: "steps.mapServers.output"
    entityType: "Server"
  - name: "evaluateServers"
    type: "evaluateEntities"
    strategy: "entitiesInRun"
    entityTypes:
      - "Server"
    referenceSteps:
      - "insertServers"
```

The square brackets in `[$$.params.webhookPayload]` make sure the next step always receives a list, whether the sender posts one object or an array.

#### Match the extract expression to your payload

| What the sender posts                                       | Extract expression                       |
| ----------------------------------------------------------- | ---------------------------------------- |
| One object: `{"identifier": "a", ...}`                      | `[$$.params.webhookPayload]`             |
| An array: `[{...}, {...}]`                                  | `[$$.params.webhookPayload]`             |
| A list inside an object: `{"items": [{...}, {...}]}`        | `[$$.params.webhookPayload.items]`       |
| A record nested in an object: `{"data": {"server": {...}}}` | `[$$.params.webhookPayload.data.server]` |

> **A wrong path doesn't fail the extract step.** It returns an empty list, and the `mapEntityType` step then reports every required field as missing. If you see that, check the extract path first.

#### Mapping rules

* Output keys must match the entity type's field names exactly, including case and underscores.
* Every record must include `name`, `identifier` and every other field the entity type marks as **Required**.
* Don't map `lastSeenOnSource`. Sprinto sets it when the record is stored.
* `identifier` must be unique and stable for each record. A later record with the same `identifier` updates the existing record instead of creating a new one.
* Send timestamps as ISO 8601 strings, for example `2026-09-09T00:00:00Z`.
* Send booleans as JSON `true` or `false`. If the sender uses the strings `"true"` and `"false"`, convert them with a comparison such as `$d.disk_encrypted = "true"`. Don't use `$boolean()` for this: `$boolean("false")` returns `true`, because any non-empty string counts as true.

### Send events

Send a `POST` request with a JSON body and the header `Content-Type: application/json`:

```bash
curl -X POST "https://app.sprinto.com/ingestion-webhook/<your-webhook-id>" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "srv-0142",
    "name": "web-prod-01",
    "os_version": "Ubuntu 24.04",
    "disk_encrypted": true,
    "last_patch_date": "2026-09-09T00:00:00Z"
  }'
```

Add the header for your webhook's auth type. See Authentication.

#### Send many records

Send them as a JSON array (or a list inside an object) in a single request, up to the size limit. One request with many records is processed as one plan run. Avoid sending many requests for the same plan in quick succession. See Known limitations.

#### Responses

<table><thead><tr><th width="104.203125">Status</th><th>Body</th><th>Meaning</th></tr></thead><tbody><tr><td><code>202</code></td><td><code>{"status": "accepted", "eventPk": "..."}</code></td><td>Event received and handed on for processing.</td></tr><tr><td><code>200</code></td><td><code>{"status": "duplicate"}</code></td><td>Sprinto already received this event. The plan does not run.</td></tr><tr><td><code>401</code></td><td><code>{"error": "Authentication failed"}</code></td><td>The key, token or signature is missing or wrong.</td></tr><tr><td><code>404</code></td><td><code>{"error": "Webhook not found"}</code></td><td>The URL is wrong, the webhook is inactive, or its ID was rotated.</td></tr><tr><td><code>413</code></td><td>Varies</td><td>The body is over the size limit. Bodies over 500 KB are rejected before the webhook reads them, and the response body may be an HTML error page rather than JSON. Check the status code, not the body.</td></tr><tr><td><code>429</code></td><td><code>{"error": "Rate limit exceeded"}</code></td><td>Too many requests in the last minute. Retry later.</td></tr><tr><td><code>500</code></td><td><code>{"error": "Internal server error"}</code></td><td>Something went wrong on Sprinto's side. Retry later.</td></tr></tbody></table>

### Authentication

Choose the **Auth Type** on the webhook and describe it in **Auth Config** (JSON). Keys, tokens and secrets are stored as Sprinto credentials, and the Auth Config refers to them by credential ID.

#### API Key

Sprinto checks a header you name for a key stored in Sprinto.

```json
{
  "headerName": "X-Api-Key",
  "expectedKeyCredentialPk": "<credential-id>"
}
```

The sender includes the key in that header:

```
X-Api-Key: <your-key>
```

#### Bearer Token

```json
{
  "expectedTokenCredentialPk": "<credential-id>"
}
```

The sender includes the token in the `Authorization` header:

```
Authorization: Bearer <your-token>
```

#### HMAC SHA256

The sender signs each request with a shared secret and Sprinto verifies the signature. Most SaaS tools that sign their webhooks (GitHub, Jira, Slack, Stripe, Linear, HubSpot and others) use this method.

<table><thead><tr><th width="207.30078125">Field</th><th width="107.74609375">Required</th><th width="118.66796875">Default</th><th>What it does</th></tr></thead><tbody><tr><td><code>secretCredentialPk</code></td><td>Yes</td><td></td><td>Credential ID of the shared secret.</td></tr><tr><td><code>signatureHeaderName</code></td><td>Yes</td><td></td><td>Header that carries the signature, for example <code>X-Hub-Signature-256</code>.</td></tr><tr><td><code>signaturePrefix</code></td><td>No</td><td>none</td><td>Text before the signature that Sprinto strips, for example <code>sha256=</code>.</td></tr><tr><td><code>signatureEncoding</code></td><td>No</td><td><code>hex</code></td><td><code>hex</code> or <code>base64</code>.</td></tr><tr><td><code>signedStringTemplate</code></td><td>No</td><td><code>${body}</code></td><td>What was signed. Can combine <code>${body}</code>, <code>${timestamp}</code>, <code>${method}</code> and <code>${url}</code>, for example <code>v0:${timestamp}:${body}</code>.</td></tr><tr><td><code>timestampHeaderName</code></td><td>No</td><td>none</td><td>Header that carries the request timestamp in Unix seconds. When set, requests without it are rejected.</td></tr><tr><td><code>maxTimestampAgeSecs</code></td><td>No</td><td><code>300</code></td><td>Rejects requests whose timestamp is more than this many seconds from the current time.</td></tr><tr><td><code>signatureParseMode</code></td><td>No</td><td><code>direct</code></td><td><code>direct</code> if the header holds only the signature. <code>key_value</code> if it holds pairs such as <code>t=1700000000,v1=abc123</code>.</td></tr><tr><td><code>signatureKey</code></td><td>For <code>key_value</code></td><td></td><td>Key of the signature in a <code>key_value</code> header, for example <code>v1</code>.</td></tr><tr><td><code>timestampKey</code></td><td>For <code>key_value</code></td><td></td><td>Key of the timestamp in a <code>key_value</code> header, for example <code>t</code>.</td></tr></tbody></table>

GitHub example:

```json
{
  "secretCredentialPk": "<credential-id>",
  "signatureHeaderName": "X-Hub-Signature-256",
  "signaturePrefix": "sha256="
}
```

Stripe example:

```json
{
  "secretCredentialPk": "<credential-id>",
  "signatureHeaderName": "Stripe-Signature",
  "signatureParseMode": "key_value",
  "signatureKey": "v1",
  "timestampKey": "t",
  "signedStringTemplate": "${timestamp}.${body}"
}
```

> **Sign the exact bytes you send.** Sprinto computes the signature over the raw request body. If your code signs a JSON object and then serializes it again before sending, key order or whitespace can change and the signature won't match.

#### No Auth

Sprinto accepts any request to the URL. Use it only for testing.

### Duplicate events

Sprinto ignores events it has already received, so a sender that retries won't cause extra plan runs. How it decides depends on the **Event ID Config**.

| Event ID Config                             | An event counts as a duplicate when                                                                                               |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Not set                                     | Its body is byte-for-byte identical to an earlier event.                                                                          |
| `{"source": "header", "key": "X-Event-Id"}` | The value of that header matches an earlier event, even if the body is different.                                                 |
| `{"source": "body", "path": "event.id"}`    | The value at that path in the body matches an earlier event, even if the rest of the body is different. `path` uses dot notation. |

If the configured header or body field is missing from a request, Sprinto falls back to comparing the whole body.

> **Point the Event ID at a value that is unique for each event.** If it points at something that repeats, such as a region, type or status, every event after the first is treated as a duplicate. Good choices are the sender's delivery ID header (for example `X-GitHub-Delivery`) or an event ID field in the body.

When you test by hand, change the value the Event ID reads each time. With no Event ID Config, change any part of the body.

### Filter events

A **Filter Expression** is an optional JSONata expression that decides whether an event runs the plan. It is evaluated against the request body, so refer to fields directly, without `params.webhookPayload`:

```
event_type = "server.updated" and environment = "production"
```

* If the expression returns false, the event is marked **filtered** and the plan does not run.
* If the expression can't be evaluated (for example, because of a syntax error), the event is not filtered out and the plan runs.

### Map values to plan parameters

A **Runtime Params Mapping** copies values from the body into named plan parameters. Each key is a parameter name. Each value is a dot-notation path into the body, or `$` for the whole body.

```json
{
  "region": "server.region",
  "source": "sender.name"
}
```

In the plan these are available as `params.region` and `params.source`. `params.webhookPayload` is always set as well.

If the plan declares a required parameter that has no default and the mapping doesn't provide it, the event fails with `Required parameter "<name>" is missing`.

### URL verification challenges

Some services check a webhook URL before they send real events, by sending a challenge and expecting it echoed back. For services that send the challenge in a POST body (for example, Slack's `url_verification`), set a **Challenge Config**:

```json
{
  "type": "echo_field",
  "typeField": "type",
  "typeValue": "url_verification",
  "challengeField": "challenge"
}
```

When a request's `typeField` equals `typeValue`, Sprinto responds `200` with `{"challenge": "<value>"}` and does not record an event or run the plan.

### Limits

| Limit                         | Value                  | Notes                                                                                                      |
| ----------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| Request body size             | 500 KB (512,000 bytes) | Applies to every webhook. **Max Payload Bytes** can set a lower limit for a webhook, but not a higher one. |
| Rate Limit Per Minute         | Default 60             | Can be set from 1 to 1,000 per webhook.                                                                    |
| Plan runs per plan            | One at a time          | See Known limitations.                                                                                     |
| Processing attempts per event | 3                      | See below.                                                                                                 |

If processing an event fails before its plan run starts, Sprinto tries again after 30 seconds, then after 60 seconds. After the third failed attempt the event is marked **failed**. Failures inside a plan run are not retried this way. Check the plan run for those.

### Known limitations

* **One run per plan at a time.** While a plan is running, other events for the same plan are not queued. The events still show **completed**, but no records are stored from them, and their plan runs stay on **Waiting for live progress**. To avoid this, send many records in one request instead of many requests, and leave enough time between requests for the previous run to finish.
* **"Completed" isn't linked to the run's outcome.** An event shows **completed** once it is handed to the plan engine. To confirm records were stored, check the plan run linked from the event.

Improvements to both are on the roadmap.

### Monitor events

Open a webhook to see **Recent Events**. Each event shows its status, event ID, when it was received and processed, and a preview of the payload.

| Status     | Meaning                                                                                     |
| ---------- | ------------------------------------------------------------------------------------------- |
| Received   | Recorded and waiting to be processed.                                                       |
| Processing | Being checked against the filter and prepared for a plan run.                               |
| Completed  | Handed to the plan engine. Open the plan run to see whether records were stored.            |
| Filtered   | The filter expression returned false. No plan run.                                          |
| Failed     | The event couldn't be processed, for example because a required plan parameter was missing. |

Duplicate requests are not listed as events. They only get the `duplicate` response.

### Troubleshooting

| What you see                                                                | Likely cause                                                               | What to do                                                                         |
| --------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Map step reports every required field missing                               | The extract step reads a path that doesn't exist and returns an empty list | Use `[$$.params.webhookPayload]`, plus the path to your records if they are nested |
| Map step rejects one or two fields                                          | A key doesn't match the entity field name, or a value has the wrong type   | Compare your mapping with the entity type definition                               |
| Event **completed** but the plan run stays on **Waiting for live progress** | Another run of the same plan was in progress when the event arrived        | Send the records again. Batch them into one request, or space requests out         |
| Event **completed**, run finished, but no data                              | A step in the plan run failed                                              | Open the plan run and check the failed step                                        |
| Every request returns `duplicate`                                           | The Event ID reads a value that doesn't change between events              | Point the Event ID at a unique header or field                                     |
| Event **filtered** unexpectedly                                             | The filter expression returns false for this body                          | Test the expression against the payload shown in **Recent Events**                 |
| Event **failed** with `Required parameter ... is missing`                   | The plan needs a parameter the mapping doesn't provide                     | Add it to the Runtime Params Mapping, or give the parameter a default in the plan  |
| `413`                                                                       | The body is over 500 KB                                                    | Split the records across several requests                                          |
| `401` on every request                                                      | Wrong header name or format, or the body changed after it was signed       | Compare your request with the webhook's Auth Config                                |
| `404`                                                                       | Wrong URL, the webhook is inactive, or the ID was rotated                  | Copy the URL again from the webhook page                                           |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.sprinto.com/ingestion/webhooks/ingestion-webhooks-technical-guide.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
