API96Astro API

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" }
  }'
FieldRequiredMeaning
capabilityYesA report capability id
inputYesThat capability's request body, exactly as for POST /v1/{family}/{capability}
brandingNobrand_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
languageNoen, 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:

StatusMeaning
queuedAccepted and waiting to run
runningBeing rendered
succeededDone; download_url is set
failedDid not render; error has a code and message. Nothing is charged
expiredSucceeded, 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://localhost for 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

TypeSent whenEnvironments
job.succeededA PDF job finished. data is the job exactly as GET /v1/jobs/{id} returns it, with a fresh download_urlThe job's environment
job.failedA PDF job failed for good (nothing was charged). data is the job, with its errorThe job's environment
credits.lowYour 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 thresholdLive
billing.payment_failedA renewal payment failed and the plan is in its 7-day grace periodLive
billing.restrictedThe 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.startedOpt-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.thresholdOverage reached 50 % or 80 % of the monthly cap (data.percent); once each per periodLive
overage.cap_reachedOverage reached the cap, or a call was refused for it: further calls that need overage get 429 spend_cap_reached until the next periodLive
webhook.testYou pressed Send test event in the portalThe 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"
  }
}
HeaderMeaning
Astro-Webhook-IdThe event id. It stays the same on every retry and resend: use it to deduplicate
Astro-Webhook-TimestampWhen this attempt was signed, in Unix seconds
Astro-Signaturev1= 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 2xx within 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 the status in data, 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:

  1. Read the raw body bytes, before any JSON parsing.
  2. Refuse a timestamp more than 5 minutes from your clock (this stops replays).
  3. Compute HMAC-SHA256 over {id}.{timestamp}.{body}, using the whole secret string, whsec_ prefix included, as the key.
  4. Compare it with each v1= value in Astro-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 False

curl, 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.

On this page