Skip to content

Commit 0d1ec05

Browse files
author
Karl Rankla
committed
docs(webhooks): document file data delivery (base64 & multipart)
Add a File Data Delivery page covering the deliveryMode setting (json_base64 and binary_multipart), the event_attachments payload, the multipartConfig fields (fileFieldName, fileFieldStrategy, fileSource, extraFields), size limits and skip/413 behavior. Document multipart signature verification (deterministic sha256 + canonical-metadata payload) in the security page, and cross-link from the payload-structure overview.
1 parent 679981d commit 0d1ec05

3 files changed

Lines changed: 236 additions & 0 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
---
2+
sidebar_position: 3
3+
title: "File Data Delivery"
4+
---
5+
6+
# File Data Delivery
7+
8+
[[API Docs](/api/webhooks)]
9+
[[SDK](https://www.npmjs.com/package/@epilot/webhooks-client)]
10+
11+
Some events carry one or more files — for example **File Created** (`event_FileCreated`). For these **file events**, a webhook can deliver the actual file *bytes* to your endpoint, not just a reference to them.
12+
13+
You choose how the bytes are delivered with the webhook's `deliveryMode`:
14+
15+
- **`json_base64`** — each file is Base64-encoded and embedded in the JSON payload. Supports JSONata transformation.
16+
- **`binary_multipart`** — files are sent as binary parts in a `multipart/form-data` request. Best for ERP systems and large files.
17+
18+
:::info
19+
`deliveryMode` is **only relevant for file events**. For all other events it is absent and has no effect. A webhook is a file event when its event payload contains an `event_attachments` array (see below).
20+
:::
21+
22+
## What is a file event?
23+
24+
File events are delivered using the [Event Catalog](/api/event-catalog) envelope (fields such as `_event_name`, `_event_id`, `_org_id`, and the hydrated entity), not the `metadata`/`entity` structure of [automation-trigger webhooks](./automation-trigger.md). What makes an event a *file event* is a top-level `event_attachments` array. Each entry describes one file:
25+
26+
| Field | Type | Description |
27+
| --------------- | -------- | ------------------------------------------------------------------ |
28+
| `entity_id` | string | Entity ID of the file |
29+
| `filename` | string? | Name of the file (e.g. `invoice.pdf`) |
30+
| `mime_type` | string? | MIME type (e.g. `application/pdf`) |
31+
| `size_bytes` | number? | File size in bytes |
32+
| `readable_size` | string? | Human-readable size (e.g. `"200 KB"`) |
33+
| `s3ref` | object? | Internal storage reference (`bucket`, `key`) — not directly accessible to you |
34+
| `version_index` | number | File version index (`0` for newly created files) |
35+
36+
```json title="File event payload (abbreviated)"
37+
{
38+
"_event_name": "FileCreated",
39+
"_event_id": "01F130Q52Q6MWSNS8N2AVXV4JN",
40+
"_event_time": "2026-06-18T10:00:00.000Z",
41+
"_org_id": "739224",
42+
"operation": "createEntity",
43+
"event_attachments": [
44+
{
45+
"entity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
46+
"filename": "invoice.pdf",
47+
"mime_type": "application/pdf",
48+
"size_bytes": 204800,
49+
"readable_size": "200 KB",
50+
"version_index": 0
51+
}
52+
]
53+
// ...plus the hydrated entity (e.g. "file": { ... }) and other event fields
54+
}
55+
```
56+
57+
## Default: references only
58+
59+
If you do **not** set a `deliveryMode`, the webhook is delivered as a normal JSON payload. The `event_attachments` references are included, but **the file binaries are not loaded or transmitted** — your endpoint receives the references only and is responsible for fetching the files itself if needed.
60+
61+
To deliver the actual bytes, opt in by choosing one of the two delivery modes below. In the webhook configuration UI this is the *"This event can include attached files — include them in the payload?"* setting.
62+
63+
## Mode 1 — JSON (Base64)
64+
65+
Set `deliveryMode: "json_base64"`. Each attached file is downloaded, Base64-encoded, and appended to the payload as a `file_data` array. The original `event_attachments` references remain in place.
66+
67+
Each `file_data` entry contains:
68+
69+
| Field | Type | Description |
70+
| ------------ | ------ | ------------------------------------ |
71+
| `base64` | string | The file bytes, Base64-encoded |
72+
| `mime_type` | string | MIME type of the file |
73+
| `filename` | string | Original filename |
74+
| `size_bytes` | number | File size in bytes |
75+
76+
```json title="json_base64 payload"
77+
{
78+
"_event_name": "FileCreated",
79+
"_org_id": "739224",
80+
"event_attachments": [
81+
{ "entity_id": "3fa85f64-...", "filename": "invoice.pdf", "mime_type": "application/pdf", "size_bytes": 204800, "version_index": 0 }
82+
],
83+
"file_data": [
84+
{
85+
"base64": "JVBERi0xLjQKJcfsj6IK...",
86+
"mime_type": "application/pdf",
87+
"filename": "invoice.pdf",
88+
"size_bytes": 204800
89+
}
90+
]
91+
// ...plus other event fields
92+
}
93+
```
94+
95+
The request is sent as `application/json`. A [JSONata transformation](./customization.md) — if configured — runs **after** `file_data` is added, so your expression can reference the encoded files.
96+
97+
## Mode 2 — Binary (multipart/form-data)
98+
99+
Set `deliveryMode: "binary_multipart"`. Files are sent as binary parts in a `multipart/form-data` request (`Content-Type: multipart/form-data; boundary=...`). This avoids the ~33% size overhead of Base64 and is the format most ERP systems and file-import endpoints expect.
100+
101+
The body is shaped by the optional `multipartConfig` object:
102+
103+
| Field | Default | Description |
104+
| -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
105+
| `fileFieldName` | `"file"` | Name of the binary form part. The same name is reused for every part when multiple files are sent. |
106+
| `fileFieldStrategy` | `"single"` | `single` fails the delivery if the picker resolves to more than one attachment. `multi` accepts any non-empty count. |
107+
| `fileSource` | `"event_attachments"` | JSONata expression selecting which attachment(s) to send. When it resolves to `undefined`, `null`, or `[]`, the event is silently skipped. Validated at save time. |
108+
| `extraFields` | `{}` | Map of `formFieldName → JSONata expression` for additional **scalar** form parts (e.g. an entity reference). |
109+
110+
```text title="binary_multipart request body"
111+
--boundary
112+
Content-Disposition: form-data; name="file"; filename="invoice.pdf"
113+
Content-Type: application/pdf
114+
115+
<binary bytes>
116+
--boundary
117+
Content-Disposition: form-data; name="entityId"
118+
119+
3fa85f64-5717-4562-b3fc-2c963f66afa6
120+
--boundary--
121+
```
122+
123+
### Picking files with `fileSource`
124+
125+
`fileSource` is a JSONata expression evaluated against the event payload. Use it to filter or select attachments:
126+
127+
```text title="fileSource examples"
128+
event_attachments // all attachments (default)
129+
event_attachments[0] // only the first
130+
event_attachments[mime_type='application/pdf'] // only PDFs
131+
event_attachments[mime_type='application/pdf'][0]
132+
```
133+
134+
If `fileFieldStrategy` is `single` (the default) and `fileSource` returns more than one attachment, the delivery **fails** — narrow the expression (e.g. add `[0]`) or switch to `multi`.
135+
136+
### Adding scalar fields with `extraFields`
137+
138+
Each `extraFields` value is a JSONata expression evaluated against the event payload, then passed through [environment-secret resolution](../environments-secrets.md) (the same as custom headers). A few rules:
139+
140+
- **Quote static literals.** A bare `"business_partner"` is treated as a path lookup and yields `undefined`. For a literal value, single-quote it *inside* the JSON string: `"'business_partner'"`.
141+
- Expressions yielding `undefined` or `null` silently omit the field.
142+
- Object/array results are JSON-stringified before being appended.
143+
144+
```json title="multipartConfig example"
145+
{
146+
"deliveryMode": "binary_multipart",
147+
"multipartConfig": {
148+
"fileFieldName": "file",
149+
"fileFieldStrategy": "single",
150+
"fileSource": "event_attachments[0]",
151+
"extraFields": {
152+
"entityId": "event_attachments[0].entity_id",
153+
"entityType": "'business_partner'"
154+
}
155+
}
156+
}
157+
```
158+
159+
:::note
160+
The main `jsonataExpression` does **not** apply in `binary_multipart` mode — shape the body with `fileSource` and `extraFields` instead.
161+
:::
162+
163+
## Choosing a mode
164+
165+
| | `json_base64` | `binary_multipart` |
166+
| --- | --- | --- |
167+
| Transport | JSON (`application/json`) | `multipart/form-data` |
168+
| Size overhead | ~33% (Base64) | None (raw bytes) |
169+
| File metadata | In each `file_data` entry | Multipart part headers (`filename`, `Content-Type`) + your `extraFields` |
170+
| JSONata transform | Yes (full payload) | Only via `fileSource` / `extraFields` |
171+
| Best for | Lightweight integrations, small files | ERP systems, large files |
172+
173+
## Limits and behavior
174+
175+
- **Maximum file size: 100 MB per file.** Larger files are rejected with HTTP status `413` and code `FILE_TOO_LARGE`; the delivery is not retried.
176+
- **No attachments → skipped.** If a file event arrives with no matching attachments, no HTTP request is sent and no error notification is raised — the event is marked *skipped* (`NO_FILE_ATTACHMENTS`).
177+
- Files are buffered in memory during delivery, which is why the 100 MB ceiling applies regardless of mode.
178+
179+
## Signature verification
180+
181+
Both modes are signed using the [Standard Webhooks](../security.md) `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers.
182+
183+
- **`json_base64`** is signed over the full JSON request body — verify it exactly like any other webhook (see [Security](../security.md)).
184+
- **`binary_multipart`** cannot be signed over the raw body, because the multipart boundary is randomized. Instead, epilot signs a **deterministic content string** derived from the file bytes and their metadata. See [Verifying multipart signatures](../security.md#verifying-multipart-signatures).
185+
186+
:::caution
187+
`extraFields` values are **not** part of the signed content. Do not rely on them for security-sensitive data.
188+
:::

‎docs/integrations/webhooks/payload-structure/intro.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Webhook payloads vary by trigger type. The two trigger types are:
1717

1818
Every payload contains a `metadata` object with the organization ID and event context. The `entity` object holds the primary entity data. The `relations` and `activity` objects are optional and depend on the webhook configuration.
1919

20+
:::tip
21+
For events that carry files (e.g. **File Created**), a webhook can also deliver the actual file bytes — as Base64 in the JSON payload or as `multipart/form-data`. See [File Data Delivery](./file-delivery.md).
22+
:::
23+
2024
```json title="Webhook payload structure"
2125
{
2226
"metadata": {

‎docs/integrations/webhooks/security.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,50 @@ async function verifyWebhookFull(req: Request, orgId: string): Promise<boolean>
156156
}
157157
```
158158

159+
## Verifying Multipart Signatures
160+
161+
When a webhook uses [`binary_multipart` file delivery](./payload-structure/file-delivery.md), the signature **cannot** be computed over the raw request body — the `multipart/form-data` boundary is randomized on each request. Instead, epilot signs a **deterministic content string** derived from the delivered files.
162+
163+
The signed body is built per file as:
164+
165+
```text title="Per-file signed segment"
166+
<sha256_hex_of_file_bytes>.<canonical_metadata_json>
167+
```
168+
169+
where `canonical_metadata_json` is the file's metadata serialized with its keys in **alphabetical order**:
170+
171+
```json title="Canonical metadata (keys sorted alphabetically)"
172+
{"entity_id":"...","filename":"...","mime_type":"...","size_bytes":204800,"version_index":0}
173+
```
174+
175+
When multiple files are sent, each per-file segment is joined with a newline (`\n`) in the order the files appear in the request. This joined string is the `request_body` used in the signed content format above — everything else (the `webhook-id.webhook-timestamp.` prefix, the `v1a`/`v1` signatures) works exactly as for JSON webhooks.
176+
177+
```typescript title="Reconstruct the signed body for a single-file multipart request"
178+
import crypto from "node:crypto";
179+
180+
// `fileBuffer` is the raw bytes of the received file part.
181+
// filename / mime_type are read from the part's Content-Disposition and
182+
// Content-Type headers; size_bytes is the part's byte length. entity_id and
183+
// version_index are not in the part — deliver them yourself via `extraFields`.
184+
function multipartSignedBody(
185+
fileBuffer: Buffer,
186+
metadata: {
187+
entity_id: string;
188+
filename: string;
189+
mime_type: string;
190+
size_bytes: number;
191+
version_index: number;
192+
}
193+
): string {
194+
const sha256 = crypto.createHash("sha256").update(fileBuffer).digest("hex");
195+
// Keys MUST be serialized in alphabetical order
196+
const canonicalMeta = JSON.stringify(metadata, Object.keys(metadata).sort());
197+
return `${sha256}.${canonicalMeta}`;
198+
}
199+
```
200+
201+
Verify this reconstructed string with the same `v1`/`v1a` logic shown above. Note that `extraFields` scalar parts are **not** included in the signed content.
202+
159203
## Fetching the Public Key
160204

161205
To fetch your organization's public key, include your organization ID as a query parameter:

0 commit comments

Comments
 (0)