API reference / PDF jobs
PDF jobs
Render any report capability as a branded PDF in the background: submit a job, poll it, then download the file from its signed link.
3 operations. Submitting and polling take your key in the Authorization header; the download link needs none. Instead of polling, a webhook can tell you when a job finishes. Schemas come from the OpenAPI document; the walk-through is in the PDF jobs and webhooks guide.
Render a report as a PDF
POST
https://api.96astro.com/v1/jobs/pdfQueues a PDF of any report capability. The response is 202 with the job; poll GET /v1/jobs/{job_id} until status is succeeded, then download download_url. Branding is optional. Sandbox keys get a SANDBOX watermark, 5 PDFs a day, free.
- Credits
- The report's own credits + 120 (report-career: 10 credits + 120)
- Charged
- Reserved when the job is accepted (
meta.credits_reserved), kept on success, released if it fails. Re-downloading is free. - Sandbox
- Free, 5 PDF jobs a day, with a SANDBOX watermark
- Response
- 202 Accepted with the job; its URL in the
Locationheader
| Header | Value and description |
|---|---|
AuthorizationRequired | Bearer YOUR_API_KEYYour sandbox (sk_test_...) or live (sk_live_...) key. X-API-Key: <key> is accepted instead. |
Content-TypeRequired | application/jsonThe body is JSON. |
Idempotency-KeyOptional | <unique string>Makes retries safe: a repeated key with the same body replays the stored response and is never charged twice. (1 to 200 characters) |
| Field | Description |
|---|---|
capabilitystringRequired | A report capability id, e.g. report-marriage. Its own credits are charged too. Allowed values: 32 valuesbnn-graha-phalaclassical-kundli-reportdetailed-lifelal-kitab-predictionlongevity-analysismatching-reportprediction-bhaveshprediction-classicsprediction-dashaprediction-gemprediction-life-reportprediction-lucky-infoprediction-nakshatra-interpprediction-natureprediction-physicalprediction-planetary-dispositionsreport-careerreport-childrenreport-educationreport-foreign-travelreport-healthreport-marriagereport-politicsreport-propertyreport-relocationreport-romancereport-spiritualreport-wealthsynastry-fullsynastry-predictionswestern-detailed-reportwestern-yearly-forecastExample: |
inputobjectRequired | That capability's request body, exactly as for POST /v1/{family}/{capability}. |
brandingobject | nullOptional | Optional branding. Without it the PDF is plain. Default: |
branding.brand_namestring | nullOptional | Printed on the cover and in the running head. Default: Limits: at most 60 characters Example: |
branding.accentstring | nullOptional | Hex colour for headings and rules, e.g. #1f4fd1. Must reach 4.5:1 contrast against white. Default: Example: |
branding.logostring | nullOptional | data:image/png;base64,... or data:image/svg+xml;base64,..., at most 200 KB. SVG is sanitised. Default: Limits: at most 300000 characters |
branding.footer_notestring | nullOptional | Printed at the foot of every page. Default: Limits: at most 200 characters Example: |
languagestringOptional | Document language. English only for now. Default: Allowed value: en |
Example request
curl -X POST https://api.96astro.com/v1/jobs/pdf \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-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",
"footer_note": "Consultations: jyotish.example"
}
}'const response = await fetch("https://api.96astro.com/v1/jobs/pdf", {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
"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",
"footer_note": "Consultations: jyotish.example"
}
}),
});
const { data, meta } = await response.json();
console.log(data);import { Astro96Client } from "@96astro/api";
const astro = new Astro96Client({ apiKey: "YOUR_API_KEY" });
const { data, meta } = await astro.createPdfJob({
"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",
"footer_note": "Consultations: jyotish.example"
}
});
console.log(data);import requests
response = requests.post(
"https://api.96astro.com/v1/jobs/pdf",
headers={
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
json={
"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",
"footer_note": "Consultations: jyotish.example",
},
},
timeout=60,
)
response.raise_for_status()
data = response.json()["data"]
print(data)from astro96_api import Astro96Client
astro = Astro96Client(api_key="YOUR_API_KEY")
res = astro.create_pdf_job("report-career", {
"datetime": "1990-10-18T14:30:00",
"timezone": "Asia/Kolkata",
"latitude": 28.6139,
"longitude": 77.209,
}, branding={
"brand_name": "Shree Jyotish Kendra",
"accent": "#1f4fd1",
"footer_note": "Consultations: jyotish.example",
})
print(res.data)<?php
$ch = curl_init('https://api.96astro.com/v1/jobs/pdf');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer YOUR_API_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'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',
'footer_note' => 'Consultations: jyotish.example',
],
]),
]);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
print_r($result['data']);package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
body := strings.NewReader(`{
"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",
"footer_note": "Consultations: jyotish.example"
}
}`)
req, err := http.NewRequest("POST", "https://api.96astro.com/v1/jobs/pdf", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}Example response
{
"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": []
}
}The example job of the PDF jobs guide; ids, times and the token differ on every job.
Error responses
- 400invalid_request
- 401invalid_key, expired_key
- 402payment_required
- 403account_suspended
- 409idempotency_conflict
- 413payload_too_large
- 422invalid_request, unsupported_language
- 429rate_limited, quota_exhausted, spend_cap_reached, project_budget_exceeded
- 500computation_failed
Errors are never charged. What each status means and what to do: errors.
Poll a job
GET
https://api.96astro.com/v1/jobs/{job_id}The job's status, timestamps, error and, once it succeeded, a signed download URL. Only the organisation and environment (sandbox or live) that created a job can see it. Free.
- Credits
- Free: polling is never charged
- Rate limit
- 1 unit per poll; poll every few seconds with backoff, or use a webhook
- Status
queued→running→succeededorfailed;expiredonce the file is deleted- Visible to
- Keys of the organization and environment (sandbox or live) that created the job
| Header | Value and description |
|---|---|
AuthorizationRequired | Bearer YOUR_API_KEYYour sandbox (sk_test_...) or live (sk_live_...) key. X-API-Key: <key> is accepted instead. |
| Parameter | Description |
|---|---|
job_idstringRequired | The job's id, from the 202 answer of POST /v1/jobs/pdf (also in its Location header). Limits: format uuid Example: |
Example request
curl https://api.96astro.com/v1/jobs/2c863278-518b-407e-8234-0ba0f824404c \
-H "Authorization: Bearer YOUR_API_KEY"const response = await fetch("https://api.96astro.com/v1/jobs/2c863278-518b-407e-8234-0ba0f824404c", {
headers: {
"Authorization": "Bearer YOUR_API_KEY",
},
});
const { data, meta } = await response.json();
console.log(data);import { Astro96Client } from "@96astro/api";
const astro = new Astro96Client({ apiKey: "YOUR_API_KEY" });
const { data, meta } = await astro.getJob("2c863278-518b-407e-8234-0ba0f824404c");
console.log(data);import requests
response = requests.get(
"https://api.96astro.com/v1/jobs/2c863278-518b-407e-8234-0ba0f824404c",
headers={
"Authorization": "Bearer YOUR_API_KEY",
},
timeout=60,
)
response.raise_for_status()
data = response.json()["data"]
print(data)from astro96_api import Astro96Client
astro = Astro96Client(api_key="YOUR_API_KEY")
res = astro.get_job("2c863278-518b-407e-8234-0ba0f824404c")
print(res.data)<?php
$ch = curl_init('https://api.96astro.com/v1/jobs/2c863278-518b-407e-8234-0ba0f824404c');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer YOUR_API_KEY',
],
]);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
print_r($result['data']);package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "https://api.96astro.com/v1/jobs/2c863278-518b-407e-8234-0ba0f824404c", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}Example response
{
"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/LIYyeFGLQH6CNAug-CRATAAAAABq0IgA.NynSAx6dDFQT8zUxIyAB2hbRNyymB7qVvTKYY1gdP-Y",
"download_expires_at": "2026-10-15T08:00:00Z",
"file_expires_at": "2026-10-21T06:59:09.874120Z"
},
"meta": {
"request_id": "req_1f7a0c93b2d84e6a5c19",
"api_version": "v1",
"credits_used": 0,
"warnings": []
}
}The example job of the PDF jobs guide; ids, times and the token differ on every job.
Error responses
- 401invalid_key, expired_key
- 403account_suspended
- 404not_found
- 429rate_limited, quota_exhausted, spend_cap_reached, project_budget_exceeded
Errors are never charged. What each status means and what to do: errors.
Download a PDF
GETNo API key
https://api.96astro.com/v1/files/{token}Streams the PDF for a signed token from download_url. Needs no API key. Free.
- Credits
- Free
- Auth
- None: the signed token is the credential
- Link
- Valid 24 hours (
download_expires_at); after that410 link_expired: poll the job for a fresh link - File
- Kept 7 days (
file_expires_at); after that410 file_expired: create a new job
Headers
None. Send no Authorization header: the signed token in the URL is the credential, so the link can be handed to an end user's browser.
| Parameter | Description |
|---|---|
tokenstringRequired | The signed token at the end of the job's download_url. Call download_url exactly as given; it expires at download_expires_at. Example: |
Example request
curl -o report-career-2c863278.pdf https://api.96astro.com/v1/files/LIYyeFGLQH6CNAug-CRATAAAAABq0IgA.NynSAx6dDFQT8zUxIyAB2hbRNyymB7qVvTKYY1gdP-Yconst response = await fetch("https://api.96astro.com/v1/files/LIYyeFGLQH6CNAug-CRATAAAAABq0IgA.NynSAx6dDFQT8zUxIyAB2hbRNyymB7qVvTKYY1gdP-Y");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
// The PDF's bytes; in Node, save them with fs.promises.writeFile("report-career-2c863278.pdf", pdf).
const pdf = new Uint8Array(await response.arrayBuffer());
console.log(pdf.byteLength);import { writeFile } from "node:fs/promises";
import { Astro96Client } from "@96astro/api";
const astro = new Astro96Client({ apiKey: "YOUR_API_KEY" });
const pdf = await astro.downloadFile("https://api.96astro.com/v1/files/LIYyeFGLQH6CNAug-CRATAAAAABq0IgA.NynSAx6dDFQT8zUxIyAB2hbRNyymB7qVvTKYY1gdP-Y");
await writeFile("report-career-2c863278.pdf", pdf);import requests
response = requests.get(
"https://api.96astro.com/v1/files/LIYyeFGLQH6CNAug-CRATAAAAABq0IgA.NynSAx6dDFQT8zUxIyAB2hbRNyymB7qVvTKYY1gdP-Y",
timeout=60,
)
response.raise_for_status()
with open("report-career-2c863278.pdf", "wb") as f:
f.write(response.content)from astro96_api import Astro96Client
astro = Astro96Client(api_key="YOUR_API_KEY")
pdf = astro.download_file("https://api.96astro.com/v1/files/LIYyeFGLQH6CNAug-CRATAAAAABq0IgA.NynSAx6dDFQT8zUxIyAB2hbRNyymB7qVvTKYY1gdP-Y")
with open("report-career-2c863278.pdf", "wb") as f:
f.write(pdf)<?php
$ch = curl_init('https://api.96astro.com/v1/files/LIYyeFGLQH6CNAug-CRATAAAAABq0IgA.NynSAx6dDFQT8zUxIyAB2hbRNyymB7qVvTKYY1gdP-Y');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200) {
file_put_contents('report-career-2c863278.pdf', $response);
}package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.96astro.com/v1/files/LIYyeFGLQH6CNAug-CRATAAAAABq0IgA.NynSAx6dDFQT8zUxIyAB2hbRNyymB7qVvTKYY1gdP-Y", nil)
if err != nil {
panic(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
panic(fmt.Sprint(res.Status, " ", string(out)))
}
if err := os.WriteFile("report-career-2c863278.pdf", out, 0o644); err != nil {
panic(err)
}
}Example response
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="report-career-2c863278.pdf"
Cache-Control: private, no-store
%PDF-1.7 … (the file's bytes)The body is the PDF itself; the headers are shown here.
Error responses
- 404not_found
- 410link_expired, file_expired
Errors are never charged. What each status means and what to do: errors.