Attachments
Attach files to transactional email, campaigns, automations and agent inboxes.
| GET/POST | /api/v1/attachments | Elenca o carica un allegato riusabile: `content` base64 per l'upload immediato, `size` per ottenere una `uploadUrl` firmata (file grandi).(scope: email:send|inbox:send) |
| GET/DELETE | /api/v1/attachments/:id | Metadati + URL di download firmata, oppure eliminazione.(scope: email:send|inbox:send) |
| POST | /api/v1/attachments/:id/confirm | Conferma un upload firmato (allinea dimensione e Content-Type reali).(scope: email:send|inbox:send) |
The attachments field is accepted on every email sending path:
| Path | Endpoint |
|---|---|
| Transactional | POST /api/v1/emails, POST /api/v1/messages (channel email) |
| Marketing (broadcast) | POST /api/v1/campaigns |
| Marketing (automations) | Journey message node, attachmentIds field |
| Agent Email | POST /api/v1/inboxes/:id/send, POST /api/v1/inboxes/:id/threads/:threadId/reply |
The three sources
Each item in attachments declares exactly one source:
| Shape | When to use it |
|---|---|
{ "id": "<uuid>" } | A file already uploaded with POST /api/v1/attachments. This is the recommended path: upload once, reuse across as many sends as you like. |
{ "filename": "...", "content": "<base64>" } | Small files: the content travels in the request body, so the practical limit is the few MB an HTTP body will take. |
{ "filename": "...", "url": "https://..." } | Files already published at a public https URL: we fetch them (private hosts and loopback are blocked). |
Common optional fields: contentType (inferred from the filename when absent) and contentId.
With contentId the attachment becomes inline and is referenced from the HTML as cid:<value>:
{
"html": "<p>Here is the logo: <img src=\"cid:logo\" /></p>",
"attachments": [{ "filename": "logo.png", "content": "<base64>", "contentId": "logo" }]
}Limits
| Limit | Value |
|---|---|
| Attachments per email | 20 |
| Single file size | 10 MB |
| Total size | 25 MB |
Executable extensions (exe, bat, js, jar, msi, vbs, …) are rejected with 422:
provider filters treat them as malware, and the message would land in spam or bounce, hurting
the domain's reputation. For those files, send a link instead.
Inline attachment in a send
import { readFileSync } from "node:fs";
await sending.emails.send({
from: "Acme <[email protected]>",
to: "[email protected]",
subject: "Your invoice",
html: "<p>July's invoice is attached.</p>",
attachments: [
{ filename: "invoice-2026-07.pdf", content: readFileSync("invoice.pdf").toString("base64") },
],
idempotencyKey: "invoice-sara-2026-07",
});POST /api/v1/attachments · reusable attachment
Two modes, depending on the size of the file.
Direct upload (base64 in the body, small files):
curl -X POST "https://sending.dev/api/v1/attachments" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{ "filename": "pricelist.pdf", "content": "<base64>" }'
# → 201 { "attachment": { "id": "...", "status": "ready", ... } }Signed upload (large files): ask for an uploadUrl, PUT the file, confirm.
Create the attachment declaring size and type
curl -X POST "https://sending.dev/api/v1/attachments" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{ "filename": "catalogue.pdf", "contentType": "application/pdf", "size": 4194304 }'
# → 201 { "attachment": { "id": "...", "status": "pending" }, "uploadUrl": "https://...", "expiresIn": 900 }Upload the file to uploadUrl
curl -X PUT "<UPLOAD_URL>" \
-H "Content-Type: application/pdf" \
--data-binary @catalogue.pdfThe signed URL pins Content-Type and Content-Length to what you declared in step 1.
Confirm (optional)
curl -X POST "https://sending.dev/api/v1/attachments/<ID>/confirm" \
-H "Authorization: Bearer <YOUR_API_KEY>"Confirming reconciles the real size and Content-Type. Skipping it costs you nothing: the same check runs automatically the first time the attachment is used in a send.
The TypeScript SDK wraps the three steps:
const attachment = await sending.attachments.upload({
filename: "catalogue.pdf",
data: readFileSync("catalogue.pdf"),
contentType: "application/pdf",
});
await sending.emails.send({
from: "Acme <[email protected]>",
to: "[email protected]",
subject: "The 2026 catalogue",
html: "<p>Here it is.</p>",
attachments: [{ id: attachment.id }],
idempotencyKey: "catalogue-sara-001",
});Campaigns and automations
On a campaign, attachments are declared at creation and apply to every recipient (the file sits in storage once, it is not duplicated per contact):
curl -X POST "https://sending.dev/api/v1/campaigns" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{ "name": "2026 pricelist", "fromAddress": "[email protected]", "listId": "<UUID>",
"subject": "The new pricelist", "html": "<p>Attached.</p>",
"attachments": [{ "id": "<ATTACHMENT_ID>" }] }'In automations the message node carries attachmentIds (ids of already uploaded attachments
only): the DAG stays small, and swapping the file does not require a new version of the journey.
{
"id": "welcome-mail",
"type": "message",
"preferredChannel": "email",
"fallbackChain": ["email"],
"templateByChannel": { "email": "<TEMPLATE_ID>" },
"attachmentIds": ["<ATTACHMENT_ID>"],
"next": null
}Management
| Operation | Endpoint |
|---|---|
| List | GET /api/v1/attachments |
| Metadata plus signed download (15 min) | GET /api/v1/attachments/:id |
| Delete | DELETE /api/v1/attachments/:id |
The required scope is email:send or inbox:send. GET /api/v1/emails/:id reports the
attachments of that send (id, filename, contentType, size).