Sheetfed API
Sheetfed turns HTML into a PDF. Send it a page and get back a file that prints the same every time: invoices, statements, tickets, certificates.
- Base URL
https://sheetfed.example.com/v1- Authentication
- An API key in the
Authorizationheader - Format
- JSON in, JSON out. The PDF itself comes back as a file.
- Typical render
- Under three seconds for a one-page invoice
Quickstart
Three requests take you from a string of HTML to a PDF on disk. You need curl and an API key.
1Set your key
Create a key under Settings, then API keys. Test keys start with sfd_test_ and cost nothing to use.
export SHEETFED_KEY="sfd_test_9c41e7d2a8"2Start a render
Post your HTML to /renders. Everything except html is optional.
curl https://sheetfed.example.com/v1/renders \
-H "Authorization: Bearer $SHEETFED_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Invoice 1042</h1><p>Total due: $380.00</p>",
"page_size": "letter",
"margin_mm": 18
}'Sheetfed answers 201 Created with the new render. It starts out queued.
{
"id": "rnd_7Hq2Lx",
"status": "queued",
"page_size": "letter",
"margin_mm": 18,
"landscape": false,
"pages": null,
"created_at": "2027-01-12T15:04:09Z",
"finished_at": null,
"pdf_url": null
}3Check on it
Ask for the render by its id. When status is done, pdf_url is filled in.
curl https://sheetfed.example.com/v1/renders/rnd_7Hq2Lx \
-H "Authorization: Bearer $SHEETFED_KEY"{
"id": "rnd_7Hq2Lx",
"status": "done",
"page_size": "letter",
"margin_mm": 18,
"landscape": false,
"pages": 1,
"created_at": "2027-01-12T15:04:09Z",
"finished_at": "2027-01-12T15:04:11Z",
"pdf_url": "https://sheetfed.example.com/v1/renders/rnd_7Hq2Lx/pdf"
}4Download the PDF
The file needs the same key as everything else. It is kept for 24 hours.
curl https://sheetfed.example.com/v1/renders/rnd_7Hq2Lx/pdf \
-H "Authorization: Bearer $SHEETFED_KEY" \
-o invoice-1042.pdfThe same thing in Python
One function that starts a render, waits for it and saves the file. It uses the requests library.
import os
import time
import requests
API = "https://sheetfed.example.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SHEETFED_KEY']}"}
def render_pdf(html, path):
r = requests.post(f"{API}/renders", headers=HEADERS,
json={"html": html, "page_size": "letter", "margin_mm": 18})
r.raise_for_status()
render = r.json()
while render["status"] in ("queued", "rendering"):
time.sleep(1)
r = requests.get(f"{API}/renders/{render['id']}", headers=HEADERS)
r.raise_for_status()
render = r.json()
if render["status"] == "failed":
raise RuntimeError(render["error"]["message"])
pdf = requests.get(render["pdf_url"], headers=HEADERS)
pdf.raise_for_status()
with open(path, "wb") as f:
f.write(pdf.content)
render_pdf("<h1>Invoice 1042</h1><p>Total due: $380.00</p>", "invoice-1042.pdf")Authentication
Every request carries Authorization: Bearer followed by your key. There are two kinds of key.
| Key starts with | Output | Cost |
|---|---|---|
sfd_test_ | A “TEST” watermark across every page | Free |
sfd_live_ | Clean pages | Counts toward your plan |
Keep keys on your server. A key in browser code is a key anyone can read. If one leaks, delete it under Settings and make another; the old one stops working at once.
Endpoints
Five endpoints, all under the base URL. The PDF download returns a file and the delete returns nothing. The other three return render objects like the ones in the quickstart; the list returns an array of them under data.
| Method | Path | What it does |
|---|---|---|
| POST | /renders | Start a render. Answers 201. |
| GET | /renders/{id} | Get one render and its status. |
| GET | /renders/{id}/pdf | Download the PDF. Answers 409 until the status is done. |
| GET | /renders | List your renders, newest first. Takes limit (default 20, at most 100) and after, a render id. |
| DELETE | /renders/{id} | Delete a render and its PDF straight away. Answers 204 with no body. |
Status values
A render moves left to right and stops at done or failed.
- queuedAccepted and waiting for a worker.
- renderingA worker is laying out the pages.
- done
pagesandpdf_urlare set. - failedAn
errorobject says why.
Render options
The JSON body of POST /renders.
| Field | Type | Default | Notes |
|---|---|---|---|
html | string | required | The whole page, up to 2 MB. Put CSS in a <style> tag. Images can be data URIs or https addresses. |
page_size | string | "letter" | "letter", "a4" or "legal". |
margin_mm | integer | 12 | 0 to 50, the same on all four sides. |
landscape | boolean | false | Turn the page on its side. |
footer_html | string | none | Repeats at the foot of each page. {{page}} and {{pages}} are filled in for you. |
webhook_url | string | none | Where to send the finished render, so you can skip step 3. |
Webhooks
Set webhook_url and Sheetfed posts the render object to it when the status becomes done or failed. The body is the same JSON that GET /renders/{id} returns.
Answer with any 2xx status within 5 seconds. If you don’t, Sheetfed tries three more times, one minute apart, and then gives up. A failed render looks like this:
{
"id": "rnd_Tm4c9P",
"status": "failed",
"page_size": "a4",
"margin_mm": 12,
"landscape": false,
"pages": null,
"created_at": "2027-01-12T15:20:41Z",
"finished_at": "2027-01-12T15:20:52Z",
"pdf_url": null,
"error": {
"code": "asset_unreachable",
"message": "https://shop.example.com/logo.png did not load within 10 seconds."
}
}Errors
A request that can’t be carried out gets a 4xx or 5xx status and one error object. field appears only when a single field is to blame.
{
"error": {
"code": "invalid_request",
"message": "margin_mm must be between 0 and 50.",
"field": "margin_mm"
}
}| Status | Code | When | What to do |
|---|---|---|---|
| 400 | invalid_request | A field is missing or out of range. | Fix the body. Sending it again unchanged fails again. |
| 401 | invalid_key | The key is missing, mistyped or deleted. | Check the header starts with Bearer and a space. |
| 404 | render_not_found | No render has that id, or it is more than 30 days old. | Check the id. |
| 409 | render_not_ready | You asked for the PDF before the status was done. | Keep polling, or wait for the webhook. |
| 410 | pdf_expired | The PDF was deleted after 24 hours. | Render it again. |
| 413 | html_too_large | The html field is over 2 MB. | Shrink inlined images, or link to them. |
| 429 | rate_limited | More than 60 requests in a minute. | Wait the number of seconds in the Retry-After header. |
| 500 | internal_error | Something broke on our side. | Try again in a few seconds. |
When a render fails
A render that was accepted can still fail. The request that started it got 201, so look for these in the render’s own error.code.
| Code | What happened |
|---|---|
render_timeout | Layout took longer than 30 seconds. Very long tables are the usual cause. |
asset_unreachable | An image or font didn’t load within 10 seconds. The message names the address. |
too_many_pages | The document came to more than 200 pages. |
Limits
| Limit | Value | Past it |
|---|---|---|
| Requests per key | 60 a minute | 429 rate_limited |
Size of html | 2 MB | 413 html_too_large |
| Render time | 30 seconds | The render fails with render_timeout |
| Pages in one PDF | 200 | The render fails with too_many_pages |
| How long a PDF is kept | 24 hours | 410 pdf_expired |
| How long a render record is kept | 30 days | 404 render_not_found |
If a POST times out and you aren’t sure it arrived, send it again with an Idempotency-Key header set to any unique string, the same one both times. Sheetfed returns the first render instead of starting a second. Keys are remembered for 24 hours.