Images
Attach up to five PNG, JPEG or WebP screenshots with multipart/form-data, stay inside the plan's byte budget, and retry without duplicates.
A failing test, a broken dashboard, a console full of red: sometimes a screenshot says more than
the body text. To attach images, send the alert as multipart/form-data instead of JSON.
Request format
Link to this section- Send each normal field (
title,body,category,severity) as its own form part. - Send
dataandtodo, if you use them, as form parts whose value is a JSON-encoded string, not as nested form fields. - Add one part named
attachmentper image, repeating the name, up to five times. - Keep the
AuthorizationandIdempotency-Keyheaders exactly as for JSON.
What is accepted
Link to this section| Rule | Detail |
|---|---|
| Count | Up to five images per alert. |
| Formats | PNG, JPEG and WebP. SVG, GIF, PDF and every other type are rejected with 400 INVALID_PAYLOAD. |
| Detection | The server reads each file’s leading bytes. File extensions and the MIME type you send are ignored. |
| Image budget | All images together must fit the plan’s byte budget: 1 MiB for guest accounts and 5 MiB for standard accounts by default, 20 MiB on Zona Plus. |
| Request size | The whole multipart request may be at most the image budget plus 64 KiB. Anything larger returns 413 PAYLOAD_TOO_LARGE. |
The budget is shared, not per file. Five 1.5 MiB screenshots do not fit a 5 MiB standard budget; downscale or crop them, or send fewer.
Examples
Link to this sectioncurl --request POST \
"https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify" \
--header "Authorization: Bearer $ZONA_SOURCE_TOKEN" \
--header "Idempotency-Key: build-20260726-15" \
--form "title=Build failed" \
--form "body=Unit tests failed on the release branch; screenshots attached." \
--form "category=build" \
--form "severity=high" \
--form 'data={"buildId":"2026.07.26.15","branch":"release"}' \
--form "attachment=@failure-screenshot.png" \
--form "attachment=@failure-console.png"Each attachment=@file is one image. There is no Content-Type header on purpose.
# Works in Windows PowerShell 5.1 and PowerShell 7. 5.1 has no Invoke-RestMethod -Form,
# so this builds the multipart body with HttpClient.
Add-Type -AssemblyName System.Net.Http
$eventId = 'build-' + [guid]::NewGuid().ToString() # keep this and reuse it on a retry
$files = @('D:\screenshots\failure.png', 'D:\screenshots\failure-console.png')
$client = New-Object System.Net.Http.HttpClient
$client.Timeout = [TimeSpan]::FromSeconds(15)
$client.DefaultRequestHeaders.Authorization =
New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $env:ZONA_SOURCE_TOKEN)
$client.DefaultRequestHeaders.Add('Idempotency-Key', $eventId)
$form = New-Object System.Net.Http.MultipartFormDataContent
$streams = @()
try {
$utf8 = [System.Text.Encoding]::UTF8
$form.Add((New-Object System.Net.Http.StringContent('Build failed', $utf8)), 'title')
$form.Add((New-Object System.Net.Http.StringContent('Unit tests failed on the release branch.', $utf8)), 'body')
$form.Add((New-Object System.Net.Http.StringContent('build', $utf8)), 'category')
$form.Add((New-Object System.Net.Http.StringContent('high', $utf8)), 'severity')
foreach ($path in $files) {
$stream = [System.IO.File]::OpenRead($path)
$streams += $stream
$form.Add((New-Object System.Net.Http.StreamContent($stream)), 'attachment', [System.IO.Path]::GetFileName($path))
}
$response = $client.PostAsync('https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify', $form).GetAwaiter().GetResult()
$text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
if (-not $response.IsSuccessStatusCode) { throw "Zona returned $([int]$response.StatusCode): $text" }
$text | ConvertFrom-Json
} finally {
$form.Dispose()
$streams | ForEach-Object { $_.Dispose() }
$client.Dispose()
}import json
import os
import requests
endpoint = "https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify"
headers = {
"Authorization": f"Bearer {os.environ['ZONA_SOURCE_TOKEN']}",
"Idempotency-Key": "build-20260726-16",
}
fields = {
"title": "Build failed",
"body": "Unit tests failed; screenshots attached.",
"category": "build",
"severity": "high",
"data": json.dumps({"buildId": "2026.07.26.16"}),
}
with open("failure.png", "rb") as first, open("failure-console.png", "rb") as second:
response = requests.post(
endpoint,
headers=headers,
data=fields,
# Repeated "attachment" parts need a list of tuples; a dict would keep only one.
files=[
("attachment", ("failure.png", first, "image/png")),
("attachment", ("failure-console.png", second, "image/png")),
],
timeout=15,
)
response.raise_for_status()
print(response.json())// Node.js 20 or later: FormData and fs.openAsBlob are built in.
import { openAsBlob } from 'node:fs';
const form = new FormData();
form.append('title', 'Build failed');
form.append('body', 'Unit tests failed; screenshots attached.');
form.append('category', 'build');
form.append('severity', 'high');
form.append('data', JSON.stringify({ buildId: '2026.07.26.17' }));
form.append('attachment', await openAsBlob('failure.png', { type: 'image/png' }), 'failure.png');
form.append('attachment', await openAsBlob('failure-console.png', { type: 'image/png' }), 'failure-console.png');
const response = await fetch('https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify', {
method: 'POST',
// No content-type header: fetch writes the multipart boundary itself.
headers: {
authorization: `Bearer ${process.env.ZONA_SOURCE_TOKEN}`,
'idempotency-key': 'build-20260726-17',
},
body: form,
signal: AbortSignal.timeout(15_000),
});
const result = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${result.error}`);
console.log(result.attachmentAccepted, result.notificationId);Images and idempotency
Link to this sectionThe image set is part of the payload. Zona fingerprints each file with SHA-256, so:
- Retrying with the same key, the same fields and the same files in the same order returns
200and the original alert. - Reusing the key with any different set of images, whether one was added, removed, swapped or
reordered, returns
409 IDEMPOTENCY_CONFLICT.
When the upload fails after acceptance
Link to this sectionImage storage happens after the alert itself is accepted, and it is best effort. If storage fails,
you still get 202, with the alert in the inbox and no images:
{
"idempotentReplay": false,
"attachmentAccepted": false,
"attachmentError": "UPLOAD_FAILED"
}A replay with the same key does not try the upload again; it only returns the stored result. If the images matter, send a new alert with a new key.
When attachments are switched off
Link to this sectionThe operator can pause image attachments. While they are paused, a multipart request with images
returns 403 ATTACHMENTS_DISABLED. Send the same event without images, as JSON, using a new
idempotency key.
Privacy and retention
Link to this sectionImages are private. Only the owning account can view them, through short-lived signed links in the app, and they follow the same retention window as their alert. Like the title and body, images end up on your phone, so never attach a screenshot that shows a token, a password or other secrets.