Attach files to an expiry
Add, list and remove files (certificates, contracts, reports) on an expiry.
Before you start
- Needs an API key - see API keys.
- Listing works for any role. Adding or removing files needs Editor or Admin.
- You need the expiry's
id(in the app's address bar, or from any API response).
export BASE="https://api.expiryedge.com/v1"
export TOKEN="ee_live_..."
export EXPIRY="exp_4Tq9sLm2"How uploading works
- Ask for an upload link.
- PUT the file to it (works once, for 15 minutes).
- Confirm, and the file is attached.
Max 50 MB per file, up to 50 files per expiry. HTML, SVG and XML are blocked.
Step 1: Ask for an upload link
size is the exact size in bytes (wc -c < file.pdf).
curl -s "$BASE/getExpiryAttachmentUploadUrl" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"expiryId":"'"$EXPIRY"'","file_name":"Gas Safety Certificate 2026.pdf","content_type":"application/pdf","size":184022}'{
"upload_id": "pUp7x1",
"upload_url": "https://storage.googleapis.com/...",
"headers": { "Content-Type": "application/pdf", "x-goog-content-length-range": "0,184022" },
"expires_at": "2026-09-27T14:20:00.000Z",
...
}Step 2: Send the file
PUT to upload_url with exactly the returned headers. Don't send your Authorization header here.
curl -s -X PUT "PASTE-upload_url-HERE" \
-H "Content-Type: application/pdf" \
-H "x-goog-content-length-range: 0,184022" \
--data-binary @"Gas Safety Certificate 2026.pdf"No output means success.
Step 3: Confirm the upload
curl -s "$BASE/completeExpiryAttachmentUpload" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"upload_id":"pUp7x1"}'{
"attachment": { "index": 1, "name": "Gas Safety Certificate 2026.pdf", "url": "https://firebasestorage.googleapis.com/..." },
"attachments": [ ... ]
}The file now shows on the expiry in the app, and in its Activity log.
Step 4: List the files
curl -s "$BASE/getExpiryAttachments?expiryId=$EXPIRY" -H "Authorization: Bearer $TOKEN"{ "data": [ { "index": 0, "name": "Gas Safety Certificate 2025.pdf", "url": "https://firebasestorage.googleapis.com/..." }, ... ] }Anyone with a file's url can open it. Keep it private.
Step 5: Remove a file
curl -s "$BASE/deleteExpiryAttachment" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"expiryId":"'"$EXPIRY"'","url":"PASTE-THE-OLD-FILE-url-HERE"}'The file is deleted from storage and its space freed. You can send "index": 0 instead of url, but url is safer if the list changed meanwhile.
Complete script
Run with TOKEN=... node attach.mjs exp_4Tq9sLm2 "Gas Safety Certificate 2026.pdf".
JavaScript (Node 18+), attach.mjs:
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
const BASE = 'https://api.expiryedge.com/v1';
const auth = { Authorization: `Bearer ${process.env.TOKEN}`, 'Content-Type': 'application/json' };
const [expiryId, filePath] = process.argv.slice(2);
async function api(name, body) {
const res = await fetch(`${BASE}/${name}`, { method: 'POST', headers: auth, body: JSON.stringify(body) });
const json = await res.json();
if (!res.ok) throw new Error(`${name}: ${res.status} ${json.code} ${json.error}`);
return json;
}
const bytes = await readFile(filePath);
const ticket = await api('getExpiryAttachmentUploadUrl', {
expiryId, file_name: basename(filePath), content_type: 'application/pdf', size: bytes.length,
});
const put = await fetch(ticket.upload_url, { method: 'PUT', headers: ticket.headers, body: bytes });
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
const done = await api('completeExpiryAttachmentUpload', { upload_id: ticket.upload_id });
console.log('Attached:', done.attachment.name);Python (pip install requests), attach.py:
import os, sys, requests
BASE = "https://api.expiryedge.com/v1"
AUTH = {"Authorization": f"Bearer {os.environ['TOKEN']}"}
expiry_id, file_path = sys.argv[1], sys.argv[2]
def api(name, body):
r = requests.post(f"{BASE}/{name}", json=body, headers=AUTH)
if not r.ok:
raise SystemExit(f"{name}: {r.status_code} {r.text}")
return r.json()
data = open(file_path, "rb").read()
ticket = api("getExpiryAttachmentUploadUrl", {
"expiryId": expiry_id, "file_name": os.path.basename(file_path),
"content_type": "application/pdf", "size": len(data),
})
requests.put(ticket["upload_url"], data=data, headers=ticket["headers"]).raise_for_status()
done = api("completeExpiryAttachmentUpload", {"upload_id": ticket["upload_id"]})
print("Attached:", done["attachment"]["name"])For other files change content_type (e.g. image/jpeg, image/png).
Common problems
These upload errors apply to every file guide.
| Symptom | Fix |
|---|---|
403 "requires admin or editor permissions" | You're a Viewer. Use an Editor key. |
403 STORAGE_LIMIT_REACHED | Plan storage is full. Remove files or upgrade. |
404 Expiry / Attachment not found | Wrong ID or url, or another organization. List again. |
409 TOO_MANY_ATTACHMENTS | The expiry already has 50 files. |
413 FILE_TOO_LARGE | Over 50 MB. Compress or split it. |
400 UNSUPPORTED_FILE_TYPE | HTML, SVG and XML aren't allowed. |
Step 2 returns 403 from storage | Headers didn't match exactly, or the file is bigger than size. |
410 UPLOAD_EXPIRED | Over 15 minutes since step 1. Start again. |
422 UPLOAD_MISSING | Do step 2 before step 3. |
422 UPLOAD_MISMATCH | File size or type differs from what you declared. Start again. |
409 "already completed" | Already done - the file is attached. |
Reference
Full details: openapi.yaml.
GET getExpiryAttachments?expiryId=- list files.POST getExpiryAttachmentUploadUrl- step 1: get an upload link.POST completeExpiryAttachmentUpload- step 3: attach the file.POSTorDELETE deleteExpiryAttachment- remove a file (byurlorindex).
