Tanqeet تنقيط

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

bash
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.

bash
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

PlanDocuments a month
Free200
Starter2,000
Growth10,000
Scale50,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.

FieldType · defaultMeaning
htmlstring · requiredFull document or fragment, up to 2 MB.
verifyboolean · falseReturn JSON with the report instead of the raw file.
inject_base_cssboolean · trueInject the base stylesheet (below).
fix_fontsboolean · trueSwap 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.
pageobject · {}Chromium only. Browser PDF options, e.g. {"landscape":true}.
bash
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.pdf
JavaScript
const 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.

FieldType · defaultMeaning
htmlstringFull document or fragment, up to 2 MB.
urlstringPublic http(s) page to capture instead of html.
widthinteger · 1200Viewport width in CSS pixels.
heightinteger · 800Viewport height; grows with full_page.
scalenumber · 2Device scale factor; 2 gives a sharp image.
full_pageboolean · trueCapture the whole page, not just the viewport.
paddinginteger · 24Body padding in pixels.
verifyboolean · falseReturn JSON with the report instead of the raw file.
inject_base_cssboolean · trueInject the base stylesheet (below).
fix_fontsboolean · trueSwap fonts whose text layer cannot round-trip for precomposed look-alikes (below). fix_vocalised is the old name and is still accepted.
bash
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.png

Verify text

POST /v1/verify/text

Send text, get the pre-render gates: input hygiene, missing glyphs, contextual forms and mark collision. Nothing is rendered.

FieldType · defaultMeaning
textstring · requiredPlain text to check.
bash
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.

FieldType · defaultMeaning
textstring · requiredVocalised 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_distanceinteger · 3How many edits (insert, delete, substitute a mark) a line may need to match a legal pattern before it is called broken.
strict_rhymeboolean · falseApply the stricter classical rhyme-defect rules.
bash
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.

FieldType · defaultMeaning
country"AE" | "SA" · requiredAE or SA: which rules and layout apply.
type"standard" | "simplified" · "standard"standard (full tax invoice) or simplified.
numberstring · requiredInvoice number, as printed.
issued_atstring · requiredISO date-time of issue. The time is printed on Saudi invoices and goes into the Phase 1 QR.
supply_datestringDate of supply, printed when it differs from the issue date.
currencystring · AED / SARInvoice currency; defaults to AED or SAR.
exchange_ratenumberLocal currency per unit of a foreign invoice currency; the VAT and total are then also shown in AED or SAR.
seller, buyerobject · seller requiredname_ar (required), name_en, tax_number, address_ar, address_en.
linesarray · requireddescription_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.
totalsobjectYour own subtotal, vat and total, checked against ours (gate I2).
zatca_qrstringSaudi Arabia: the base64 TLV QR your e-invoicing unit produced. Drawn into the document and read back (gate I3).
zatca_phase1_qrboolean · falseSaudi sellers outside Phase 2 only: generate a Phase 1 QR (tags 1–5).
amount_in_wordsboolean · truePrint the total in Arabic words.
hijribooleanPrint the Hijri date; on by default for Saudi invoices.
reverse_chargeboolean · falsePrint the UAE reverse-charge statement.
fieldsarrayUp to 10 extra {label_ar, label_en, value} rows, such as a PO number or the e-invoice UUID.
notes_ar, notes_enstringFree text under the totals.
verifyboolean · falseReturn JSON with the report instead of the raw file.
strictboolean · falseRefuse to return a document that fails any gate: 422 with the report.
bash
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.

bash
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:

ModeMeaning
plainDefault. The repair alone, no /ActualText at all. Reads correctly in pdftotext (poppler), PyMuPDF, PDFBox and pdfminer.
specAdditionally 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.
popplerThe same spans in visual order, for pipelines that only ever use pdftotext.
keepThe 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

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.

RequestedDrawn with
Noto Naskh ArabicAmiri
Noto Kufi ArabicAlmarai
Noto Sans ArabicVazirmatn
ChangaTajawal

All OFL-1.1, static instances, hashes recorded.

Errors

StatusMeaning
401Missing or invalid key.
413HTML over 2 MB.
422Neither html nor url, or url is not http(s).
429Monthly cap reached, playground window exceeded, or daily key limit for the address.
503Render service unreachable. Retry with backoff.