Documentation
Nine endpoints, all JSON, at https://api.tanqeet.smeepo.com: seven POST for rendering, invoices, verification and analysis, one to issue a key, one to read usage.
Get a key
One call, no signup form. Post an email address and the key comes back once; it is not sent by email and cannot be shown again. The pricing page does the same through a dialog.
POST /v1/keys
curl -X POST https://api.tanqeet.smeepo.com/v1/keys \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com"}'
{"key":"tq_…","plan":"free","monthly":200,"note":"shown once; keep it safe"}One address gets a limited number of keys a day (429 past that). A new key starts on the free plan.
Send the key on every request as a Bearer token. Paid plans are attached to the key at checkout; nothing changes in your code.
Authorization: Bearer tq_…
Usage
GET /v1/usage with the same header returns the plan, the current month, the cap and an estimate of what is used and what remains.
curl https://api.tanqeet.smeepo.com/v1/usage -H "Authorization: Bearer $TANQEET_KEY"
{"plan":"free","month":"202609","monthly":200,"used_estimate":0,"remaining_estimate":200,"note":"…"}Plans and caps
| Plan | Documents a month |
|---|---|
| Free | 200 |
| Starter | 2,000 |
| Growth | 10,000 |
| Scale | 50,000 |
Past the cap the API answers 429 until the month rolls over or you upgrade; there is no metered overage.
Authentication
Send your key in the Authorization header. No valid key: 401. Past the monthly quota: 429.
Authorization: Bearer <key>
Content-Type: application/json
The playground uses the same endpoints under /v1/playground/, no key, about 6 requests a minute per address.
Render PDF
POST /v1/render/pdf
Send HTML, get a PDF. A bare fragment is wrapped in <html lang="ar" dir="rtl">; a full document is used as it is.
| Field | Type · default | Meaning |
|---|---|---|
html | string · required | Full document or fragment, up to 2 MB. |
verify | boolean · false | Return JSON with the report instead of the raw file. |
inject_base_css | boolean · true | Inject the base stylesheet (below). |
fix_fonts | boolean · true | Swap fonts whose text layer cannot round-trip for precomposed look-alikes (below). fix_vocalised is the old name and is still accepted. |
text_layer | "plain" | "spec" | "poppler" | "keep" · "plain" | Text-layer mode (below). plain repairs the text layer and adds no /ActualText; spec adds /ActualText spans for viewer copy-paste; poppler writes them in visual order; keep leaves the producer's stream untouched. |
engine | "chromium" | "weasy" · "chromium" | chromium prints from the browser and accepts page; weasy (WeasyPrint, CSS paged media) shapes with Pango. |
page | object · {} | Chromium only. Browser PDF options, e.g. {"landscape":true}. |
curl -X POST https://api.tanqeet.smeepo.com/v1/render/pdf \
-H "Authorization: Bearer $TANQEET_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>فاتورة ضريبية</h1><p>الإجمالي: <bdi class=\"money\">1,407.00 د.إ</bdi></p>","verify":true}' \
-o response.json
# without "verify": the body is the PDF itself
curl -X POST https://api.tanqeet.smeepo.com/v1/render/pdf \
-H "Authorization: Bearer $TANQEET_KEY" -H "Content-Type: application/json" \
-d @invoice.json -o invoice.pdfconst res = await fetch("https://api.tanqeet.smeepo.com/v1/render/pdf", {
method: "POST",
headers: { "Authorization": `Bearer ${process.env.TANQEET_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ html, verify: true, text_layer: "plain" }) // "plain" is the default; "spec" for viewer copy-paste
});
const { pdf_base64, render_ms, verification } = await res.json();
if (!verification.passed) {
throw new Error(verification.gates.filter(g => !g.passed).map(g => g.name).join(", "));
}
await fs.promises.writeFile("invoice.pdf", Buffer.from(pdf_base64, "base64"));Render PNG
POST /v1/render/png
Send HTML or a public URL, get a PNG. Same engine, fonts and base CSS as Render.
| Field | Type · default | Meaning |
|---|---|---|
html | string | Full document or fragment, up to 2 MB. |
url | string | Public http(s) page to capture instead of html. |
width | integer · 1200 | Viewport width in CSS pixels. |
height | integer · 800 | Viewport height; grows with full_page. |
scale | number · 2 | Device scale factor; 2 gives a sharp image. |
full_page | boolean · true | Capture the whole page, not just the viewport. |
padding | integer · 24 | Body padding in pixels. |
verify | boolean · false | Return JSON with the report instead of the raw file. |
inject_base_css | boolean · true | Inject the base stylesheet (below). |
fix_fonts | boolean · true | Swap fonts whose text layer cannot round-trip for precomposed look-alikes (below). fix_vocalised is the old name and is still accepted. |
curl -X POST https://api.tanqeet.smeepo.com/v1/render/png \
-H "Authorization: Bearer $TANQEET_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/ar/receipt/42","width":800,"scale":2}' \
-o receipt.pngVerify text
POST /v1/verify/text
Send text, get the pre-render gates: input hygiene, missing glyphs, contextual forms and mark collision. Nothing is rendered.
| Field | Type · default | Meaning |
|---|---|---|
text | string · required | Plain text to check. |
curl -X POST https://api.tanqeet.smeepo.com/v1/verify/text \
-H "Authorization: Bearer $TANQEET_KEY" -H "Content-Type: application/json" \
-d '{"text":"عربيّةٌ يمكنك التحقّق منها"}'Analyze meter & rhyme
POST /v1/analyze/meter
Send vocalised verse (علم العروض), get back the metrical scan: the meter and form of each line, any deviation named per foot, the rhyme (قافية) and any rhyme defect. Nothing is rendered; this is a different discipline from the render and verify gates above, which check that shaped Arabic reads back correctly, not whether it scans as poetry.
| Field | Type · default | Meaning |
|---|---|---|
text | string · required | Vocalised verse, one bayt per line. A line's two hemistichs split on …, *, |, a tab, or four or more spaces; a line with none is one hemistich. |
max_distance | integer · 3 | How many edits (insert, delete, substitute a mark) a line may need to match a legal pattern before it is called broken. |
strict_rhyme | boolean · false | Apply the stricter classical rhyme-defect rules. |
curl -X POST https://api.tanqeet.smeepo.com/v1/analyze/meter \
-H "Authorization: Bearer $TANQEET_KEY" -H "Content-Type: application/json" \
-d '{"text":"قِفا نَبكِ مِن ذِكرى حَبيبٍ وَمَنزِلِ … بِسِقطِ اللِوى بَينَ الدَخولِ فَحَومَلِ"}'
{"lines":[{"index":0,"broken":false,"candidates":["…"],"rhyme":{"…"}}],
"meter":{"name":"الطويل","form":"تام","darb":"مقبوض","confidence":1.0},
"rhyme":{"rawi":"…","kind":"…","type":"مقيدة"},"defects":[],"broken_lines":[],"notes":["…"]}Diacritisation is out of scope: the input is assumed already vocalised. One document call, the same as a render or a verify.
Tax invoices
POST /v1/invoice/pdf · POST /v1/invoice/png
Send the invoice as JSON, get a bilingual Arabic/English tax invoice as a PDF or PNG. What it checks and what it does not do: invoices.
| Field | Type · default | Meaning |
|---|---|---|
country | "AE" | "SA" · required | AE or SA: which rules and layout apply. |
type | "standard" | "simplified" · "standard" | standard (full tax invoice) or simplified. |
number | string · required | Invoice number, as printed. |
issued_at | string · required | ISO date-time of issue. The time is printed on Saudi invoices and goes into the Phase 1 QR. |
supply_date | string | Date of supply, printed when it differs from the issue date. |
currency | string · AED / SAR | Invoice currency; defaults to AED or SAR. |
exchange_rate | number | Local currency per unit of a foreign invoice currency; the VAT and total are then also shown in AED or SAR. |
seller, buyer | object · seller required | name_ar (required), name_en, tax_number, address_ar, address_en. |
lines | array · required | description_ar, description_en, quantity, unit_price, vat_rate (percent; 5 in the UAE, 15 in Saudi Arabia by default), discount (amount, before VAT). Up to 200 lines. |
totals | object | Your own subtotal, vat and total, checked against ours (gate I2). |
zatca_qr | string | Saudi Arabia: the base64 TLV QR your e-invoicing unit produced. Drawn into the document and read back (gate I3). |
zatca_phase1_qr | boolean · false | Saudi sellers outside Phase 2 only: generate a Phase 1 QR (tags 1–5). |
amount_in_words | boolean · true | Print the total in Arabic words. |
hijri | boolean | Print the Hijri date; on by default for Saudi invoices. |
reverse_charge | boolean · false | Print the UAE reverse-charge statement. |
fields | array | Up to 10 extra {label_ar, label_en, value} rows, such as a PO number or the e-invoice UUID. |
notes_ar, notes_en | string | Free text under the totals. |
verify | boolean · false | Return JSON with the report instead of the raw file. |
strict | boolean · false | Refuse to return a document that fails any gate: 422 with the report. |
curl -X POST https://api.tanqeet.smeepo.com/v1/invoice/pdf \
-H "Authorization: Bearer $TANQEET_KEY" -H "Content-Type: application/json" \
-d '{"country":"AE","number":"INV-2026-00147","issued_at":"2026-09-10T10:00:00",
"seller":{"name_ar":"شركة تركيب للحلول الرقمية ذ.م.م","tax_number":"100123456700003","address_ar":"الشارقة"},
"buyer":{"name_ar":"مؤسسة الخليج للتجارة العامة","tax_number":"100987654300003","address_ar":"دبي"},
"lines":[{"description_ar":"توليد المستندات العربية","unit_price":"1000.00"}],
"verify":true}'The JSON adds an invoice object: currency, subtotal, vat, total, local (foreign currency), amount_in_words and qr_tlv_base64. Without verify the body is the file and X-Tanqeet-Invoice-Total carries the total.
Amounts in words
POST /v1/tafqit
Send an amount and a currency (AED, SAR, QAR, KWD, BHD, OMR, JOD, EGP, USD), get the Arabic words. wrap (default true) adds فقط … لا غير. Free tool: tafqit.
curl -X POST https://api.tanqeet.smeepo.com/v1/tafqit \
-H "Authorization: Bearer $TANQEET_KEY" -H "Content-Type: application/json" \
-d '{"amount":"1407.50","currency":"AED"}'
{"words":"فقط ألف وأربعمائة وسبعة دراهم إماراتية وخمسون فلسًا لا غير","currency":"AED"}Response shape
Without verify the body is the file itself (application/pdf or image/png); X-Tanqeet-Render-Ms carries the timing, and a PDF also carries X-Tanqeet-Engine and X-Tanqeet-Substitutions (the number of font swaps). With verify:true:
{
"pdf_base64": "JVBERi0xLjcK…", // or "png_base64"
"render_ms": 812,
"engine": "chromium", // PDF only
"tounicode": { "fonts": 3, "entries": 412, "changed": 9, "learned": 2, "actualtext_words": 0, // PDF only
"reordered_words": 6, "widths_widened": 1, "mode": "plain", "streams": 2 },
"verification": {
"passed": true,
"substitutions": [ { "from": "Noto Naskh Arabic", "to": "Amiri", "runs": 2 } ],
"gates": [
{ "name": "G0 input hygiene", "passed": true, "detail": "no presentation forms in the input", "data": {} },
{ "name": "G1 round-trip", "passed": true, "detail": "text layer round-trips in poppler, pymupdf, pdfium",
"data": { "poppler": { "presentation_forms": 0, "words_source": 18, "words_missing": 0, "fragments_extra": 0, "match": true,
"vocalised_words": 2, "vocalised_exact": 1 },
"…": "…", "passing_extractors": ["poppler", "pymupdf", "pdfium"] } },
{ "name": "G3 missing glyphs", "passed": true, "detail": "every character has a glyph in the font that draws it", "data": {} },
{ "name": "G2 contextual forms", "passed": true, "detail": "14 words shaped contextually", "data": {} },
{ "name": "G6 mark collision", "passed": true, "detail": "worst stacked-mark overlap 0.11",
"data": { "per_font": { "Amiri-Regular.ttf": 0.11 } } },
{ "name": "G7 font composition", "passed": true,
"detail": "every Arabic run is drawn by a font with one glyph per letter; substituted Noto Naskh Arabic -> Amiri",
"data": { "substitutions": [ { "from": "Noto Naskh Arabic", "to": "Amiri", "runs": 2 } ] } }
]
}
}
verification.passed is true only when every gate passed. Each gate has a stable name, a one-line detail and data with the measurements; a PDF report carries G0, G1, G2, G3, G6 and G7. /v1/verify/text returns this object alone.
tounicode (PDF only) reports the text-layer repair: fonts touched, ToUnicode entries read, entries changed, glyphs whose text was learned from the producer's cluster markup, actualtext_words (0 in plain), reordered_words, widths_widened, the mode applied and the content streams rewritten. verification.substitutions lists every font swap as {from, to, runs}; it is empty when nothing was swapped.
The base CSS
Unless you opt out, this stylesheet is injected ahead of yours: isolates around non-Arabic runs, ragged edges, no hyphenation, tabular figures, room above the line for marks.
html{-webkit-print-color-adjust:exact;print-color-adjust:exact}
body{font-family:"Amiri","Scheherazade New",serif;line-height:1.9;text-align:start;
hyphens:none;word-break:normal;overflow-wrap:normal;font-kerning:normal}
h1,h2,h3{font-family:"Tajawal","Almarai",sans-serif;line-height:1.6}
bdi,.num,.money,.code,.date,.iban,.phone,.latin{unicode-bidi:isolate}
.num,.money,.code,.date,.iban,.phone,.latin{direction:ltr;font-variant-numeric:tabular-nums lining-nums;
font-feature-settings:"tnum" 1}
.freeform{unicode-bidi:plaintext}
td.amount{direction:ltr;text-align:left;font-variant-numeric:tabular-nums}
.tq-vocalised{font-family:"Amiri","Scheherazade New",serif}
@page{size:A4;margin:18mm 16mm}
Send inject_base_css:false to use only your own CSS. The fonts stay available by family name either way.
Font substitution
Some fonts draw a dotted letter as a dotless base plus a zero-advance dot mark. Every PDF extractor then breaks the word at the dots: فاتورة comes back as ف ات ورة. The Noto Arabic family does this. Noto Naskh Arabic also stacks its diacritics so that a shadda over a vowel overlaps by 0.26 of the smaller mark's box, above the 0.25 limit of G6.
So by default, runs that would be drawn in one of those fonts are moved to a precomposed face that reads the same, and the swap is reported in G7 and in verification.substitutions. Set fix_fonts:false to turn this off (G7 then fails and names the font to use), or add data-tq-keep-font to any element to exempt its subtree.
fix_vocalised is the old name for the same switch and is still accepted.
Text layer
Every PDF's text layer is repaired after rendering: the ToUnicode map is rewritten, marks are re-emitted after their base letters, and advance widths are widened where a font overshoots them. The mechanism and the measured results are on How we verify. text_layer picks what happens on top of that:
| Mode | Meaning |
|---|---|
plain | Default. The repair alone, no /ActualText at all. Reads correctly in pdftotext (poppler), PyMuPDF, PDFBox and pdfminer. |
spec | Additionally wraps vocalised and ligature words in /ActualText spans carrying the logical text. This is what the PDF specification says, and what pdfium (Chrome, Edge), pdf.js (Firefox) and Acrobat honour; poppler and MuPDF reverse such spans. Choose it only when your consumer is a viewer's copy-paste. |
poppler | The same spans in visual order, for pipelines that only ever use pdftotext. |
keep | The producer's stream untouched. For diagnostics. |
Fonts available
Use them by family name; nothing to load. Latin text falls to Noto Sans.
Used as sent
- Amiri
- Amiri Quran
- Scheherazade New
- Lateef
- Harmattan
- Tajawal
- Almarai
- Vazirmatn
- Readex Pro
- Markazi Text
- El Messiri
- Baloo Bhaijaan 2
- Rubik
- Cairo
- IBM Plex Sans Arabic
Installed, substituted by default
These split dotted letters into a base plus a dot mark, so their text layer cannot round-trip (G7). Each is swapped for the precomposed face that reads the same, and the swap is reported.
| Requested | Drawn with |
|---|---|
| Noto Naskh Arabic | Amiri |
| Noto Kufi Arabic | Almarai |
| Noto Sans Arabic | Vazirmatn |
| Changa | Tajawal |
All OFL-1.1, static instances, hashes recorded.
Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid key. |
413 | HTML over 2 MB. |
422 | Neither html nor url, or url is not http(s). |
429 | Monthly cap reached, playground window exceeded, or daily key limit for the address. |
503 | Render service unreachable. Retry with backoff. |