PDF jobs and webhooks
Render a report as a PDF in the background, poll the job or receive a signed webhook, and download the file.
Every capability answers directly with JSON. A report capability (any capability priced as a report, such as report-career) can also be rendered as a PDF. That runs as a job: you submit it, poll it, then download the file.
1. Submit the job
curl https://api.96astro.com/v1/jobs/pdf \
-H "Authorization: Bearer $ASTRO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7b1e4c52-0f3a-4d8e-9c61-5a2f8e3d9b10" \
-d '{
"capability": "report-career",
"input": {
"datetime": "1990-10-18T14:30:00",
"timezone": "Asia/Kolkata",
"latitude": 28.6139,
"longitude": 77.209
},
"branding": { "brand_name": "Shree Jyotish Kendra", "accent": "#1f4fd1" }
}'| Field | Required | Meaning |
|---|---|---|
capability | Yes | A report capability id |
input | Yes | That capability's request body, exactly as for POST /v1/{family}/{capability} |
branding | No | brand_name (up to 60 characters), accent (a hex colour with at least 4.5:1 contrast on white), logo (a PNG or SVG data URI, up to 200 KB) and footer_note (up to 200 characters). Without it the PDF is plain |
language | No | en, the only document language for now |
The API answers 202 Accepted, with the job's address in the Location header:
{
"data": {
"id": "2c863278-518b-407e-8234-0ba0f824404c",
"object": "job",
"kind": "pdf",
"capability": "report-career",
"status": "queued",
"attempts": 0,
"credits": 130,
"created_at": "2026-10-14T06:58:59.069427Z",
"started_at": null,
"finished_at": null,
"error": null,
"download_url": null,
"download_expires_at": null,
"file_expires_at": null
},
"meta": {
"request_id": "req_84c3f093ee178943738c",
"api_version": "v1",
"credits_reserved": 130,
"warnings": []
}
}2. Poll the job
Poll GET /v1/jobs/{id} with the same key until status is final. Polling is free. A job moves through:
| Status | Meaning |
|---|---|
queued | Accepted and waiting to run |
running | Being rendered |
succeeded | Done; download_url is set |
failed | Did not render; error has a code and message. Nothing is charged |
expired | Succeeded, but the file is past its 7-day retention and has been deleted |
A job can only be seen with a key of the organization and environment that created it. Poll every few seconds with backoff, or skip polling and receive a webhook when the job finishes.
3. Download the file
On success, download_url is a signed link that needs no API key and expires at download_expires_at, about a day later. Polling again returns a link that is still valid. The file itself is kept until file_expires_at, 7 days after it was rendered. Files are private to your account and never publicly listed.
- An expired link returns
410 link_expired: poll the job for a new one. - A file past its retention returns
410 file_expired: create a new job.
Charges
A PDF costs the report's own credits plus 120, reserved when the job is accepted (meta.credits_reserved). A failed job releases them, and re-downloading a file is free. Sandbox keys are not charged and can create 5 PDF jobs a day.
Webhooks
A webhook is an HTTPS POST we send to your server when something happens, so you do not have to poll. Add endpoints in the portal under Webhooks: each endpoint has a URL, the events it wants and its own signing secret.
- Plans: live endpoints come with the Growth plan and above. Every plan, including the trial, can add sandbox endpoints, which receive events from test keys, so you can build and test the integration first.
- Limits: up to 5 endpoints per environment.
- URLs: HTTPS on a public address. A sandbox endpoint may also use plain
http://localhostfor local development. Hosts that resolve to a private, loopback, link-local or other reserved address are refused, when you save the endpoint and again before every delivery. Redirects are not followed.
Events
| Type | Sent when | Environments |
|---|---|---|
job.succeeded | A PDF job finished. data is the job exactly as GET /v1/jobs/{id} returns it, with a fresh download_url | The job's environment |
job.failed | A PDF job failed for good (nothing was charged). data is the job, with its error | The job's environment |
credits.low | Your live balance fell below the threshold you set under Webhooks. Sent once per crossing: again only after a top-up or renewal lifts the balance back to the threshold | Live |
billing.payment_failed | A renewal payment failed and the plan is in its 7-day grace period | Live |
billing.restricted | The grace period ended unpaid: live calls answer 402 until the payment goes through. Also sent when an overage bill passes its due date unpaid (data.reason is overage_unpaid) | Live |
overage.started | Opt-in overage began this period: plan credits and top-ups are used up. data has the period, credits_used, the cap (amount, currency, credits), rate_per_1000 and cost_so_far (excluding GST) | Live |
overage.threshold | Overage reached 50 % or 80 % of the monthly cap (data.percent); once each per period | Live |
overage.cap_reached | Overage reached the cap, or a call was refused for it: further calls that need overage get 429 spend_cap_reached until the next period | Live |
webhook.test | You pressed Send test event in the portal | The endpoint's |
The request
POST /webhooks/96astro HTTP/1.1
Content-Type: application/json
User-Agent: 96Astro-Webhooks/1
Astro-Webhook-Id: evt_5f0c1e2a7b3d4c6e8f9a0b1c2d3e4f50
Astro-Webhook-Timestamp: 1760425200
Astro-Signature: v1=6f1c0c3d6b0e4c7a2d9e8f1b3a5c7d9e0f2a4b6c8d0e1f3a5b7c9d1e3f5a7b9c{
"id": "evt_5f0c1e2a7b3d4c6e8f9a0b1c2d3e4f50",
"type": "job.succeeded",
"created": 1760425200,
"environment": "live",
"project_id": "8d7f2b1e-4c3a-4e5f-9a1b-2c3d4e5f6a7b",
"data": {
"id": "2c863278-518b-407e-8234-0ba0f824404c",
"object": "job",
"kind": "pdf",
"capability": "report-career",
"status": "succeeded",
"attempts": 1,
"credits": 130,
"created_at": "2026-10-14T06:58:59.069427Z",
"started_at": "2026-10-14T06:59:01.112040Z",
"finished_at": "2026-10-14T06:59:09.874120Z",
"error": null,
"download_url": "https://api.96astro.com/v1/files/…",
"download_expires_at": "2026-10-15T08:00:00Z",
"file_expires_at": "2026-10-21T06:59:09.874120Z"
}
}| Header | Meaning |
|---|---|
Astro-Webhook-Id | The event id. It stays the same on every retry and resend: use it to deduplicate |
Astro-Webhook-Timestamp | When this attempt was signed, in Unix seconds |
Astro-Signature | v1= and the hex HMAC-SHA256 of {id}.{timestamp}.{body}, keyed with your endpoint's secret. During a secret rotation it carries two signatures, comma-separated |
environment is test for sandbox events and live otherwise. created is when the event happened, in Unix seconds. project_id is the project whose key made the job, or null for organization-wide events (credits.low, billing.*, overage.*). An endpoint can be scoped to one project in the dashboard: it then receives only that project's events.
Answering, retries and ordering
- Answer with any
2xxwithin 10 seconds. Do the real work after answering, for example from a queue. - Anything else (another status, a redirect, a timeout, a connection error) is retried 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h later: 8 attempts over about 45 hours. The delivery is then marked failed.
- An endpoint that fails every delivery for 3 days is disabled, and the Webhooks page in the portal says so. Fix the receiver, enable the endpoint again in the portal, and resend what it missed from its delivery log.
- Order is not guaranteed, and an event can arrive more than once (a retry after a timeout you did answer, or a resend). Deduplicate on
Astro-Webhook-Id, and for jobs trust thestatusindata, or poll the job, rather than the order events arrived in. - The portal keeps a 30-day delivery log per endpoint (status, response code, latency and the first 1 KB of your response body), with Resend and Send test event.
Verify the signature
Check every request before you trust it:
- Read the raw body bytes, before any JSON parsing.
- Refuse a timestamp more than 5 minutes from your clock (this stops replays).
- Compute HMAC-SHA256 over
{id}.{timestamp}.{body}, using the whole secret string,whsec_prefix included, as the key. - Compare it with each
v1=value inAstro-Signature, in constant time. Accept the request if any one matches.
Node.js (Express):
import crypto from "node:crypto";
import express from "express";
const SECRETS = [process.env.ASTRO_WEBHOOK_SECRET]; // add the new secret here while you rotate
const TOLERANCE_SECONDS = 300;
function verify(rawBody, headers) {
const id = headers["astro-webhook-id"];
const timestamp = headers["astro-webhook-timestamp"];
const signature = headers["astro-signature"];
if (!id || !/^\d+$/.test(timestamp ?? "") || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
const received = signature
.split(",")
.map((part) => part.trim())
.filter((part) => part.startsWith("v1="))
.map((part) => Buffer.from(part.slice(3), "hex"));
return SECRETS.some((secret) => {
const expected = crypto.createHmac("sha256", secret).update(signed).digest();
return received.some((sig) => sig.length === expected.length && crypto.timingSafeEqual(sig, expected));
});
}
const app = express();
app.post("/webhooks/96astro", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.headers)) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate on event.id, queue the work, then answer quickly.
res.sendStatus(204);
});
app.listen(3000);Python (any framework; pass the raw body bytes):
import hashlib
import hmac
import os
import time
SECRETS = [os.environ["ASTRO_WEBHOOK_SECRET"]] # add the new secret here while you rotate
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, headers) -> bool:
event_id = headers.get("Astro-Webhook-Id")
timestamp = headers.get("Astro-Webhook-Timestamp", "")
signature = headers.get("Astro-Signature", "")
if not event_id or not timestamp.isdigit() or not signature:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = f"{event_id}.{timestamp}.".encode() + raw_body
received = [part.strip()[3:] for part in signature.split(",") if part.strip().startswith("v1=")]
for secret in SECRETS:
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, sig) for sig in received):
return True
return Falsecurl, to test your receiver locally with a correctly signed request (shell tools cannot compare in constant time, so verify in your server code as above):
SECRET='whsec_...' # your sandbox endpoint's secret
ID="evt_local_$(date +%s)"
TS=$(date +%s) # must be within 5 minutes of your server's clock
BODY='{"id":"'"$ID"'","type":"webhook.test","created":'"$TS"',"environment":"test","data":{"message":"hello"}}'
SIG=$(printf '%s' "$ID.$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i http://localhost:3000/webhooks/96astro \
-H "Content-Type: application/json" \
-H "Astro-Webhook-Id: $ID" \
-H "Astro-Webhook-Timestamp: $TS" \
-H "Astro-Signature: v1=$SIG" \
--data-raw "$BODY"Your receiver should answer 2xx to this request, and 400 if you change one character of the body or use a timestamp older than 5 minutes. Then add the endpoint in the portal and press Send test event to try the real thing.
Rotating the secret
Rotate secret in the portal shows a new secret once. For the next 24 hours every request is signed with both the new and the previous secret, so deploy the new one to your verifier (keeping the old one in the list), then remove the old one. A secret is never shown again after it is created; if you lose it, rotate.