Now 100% free

API · v1

Check copy before it ships.

Send text, get back exactly what each screen shows, the cut point and any rule problems. Available on every paid plan.

Authentication

Create a free key under API keys in the app. Send it as a Bearer token. Keys are shown once and stored hashed, so treat them like passwords. Every call returns JSON wrapped in { data } or { error: { code, message } }, and the header X-API-Version: 1. AI rewriting is off for now: keepMeaning only uses the wording rules, and translate mode is not available.

Header · bash
curl https://truncly.app/api/v1/surfaces \
-H "Authorization: Bearer tr_live_YOUR_KEY"

POST /fit · measure

Measures text on one or more screens. Omit surfaceIds to check all of them. Each result says whether it fits, what is visible, what is hidden, the used and maximum size, detected key info and any rule problems. The response also has labels: every ad label or risk warning found in the text (for example #ad, Sponsored, Anzeige, publicité or an FCA risk warning), as [{ kind, text, regions, visibleOn, hiddenOn }] with the social screens that show it and the ones that cut it off. It is [] when none is found, and each result has its own labels for that screen. This helps you check. It isn’t legal advice. The response also has tags: the name tags found in the text (Klaviyo, Braze, Mailchimp, HubSpot, Salesforce Marketing Cloud or a plain {{name}}), as [{ key, tag, kind, label, fallback, count }], or [] when none. One API check per request.

text string
Up to 10,000 characters.
surfaceIds string[]
Screen ids from GET /surfaces, e.g. "amazon-title".
mustKeep string[]
Phrases that must stay visible (max 10).
brand string
For marketplace titles that should start with the brand.
customSurfaces object[]
Inline limits: { name, limit: { kind: "chars", max } | { kind: "pixels", box }, enforcement, ellipsis }.
sms object
{ pricePerSegment, recipients, maxSegments } for cost estimates.
Request · bash
curl -X POST https://truncly.app/api/v1/fit \
-H "Authorization: Bearer tr_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Acme Trail Running Shoes for Men, Lightweight Breathable Mesh, Non-Slip Sole, Size 10",
"surfaceIds": ["amazon-title", "google-shopping-visible"],
"brand": "Acme"
}'

POST /fix · fit it

Returns a version that fits one screen. Order: compliance clean-up, plain SMS characters and tighter wording first; then, with keepMeaning (on by default), an AI rewrite that keeps the meaning when the rules can’t make it fit; then a cut at the last whole word, only when keepMeaning is off or AI can’t run. AI uses one credit per started 1,000 characters, and only when it is asked and it works (misses are free up to your monthly credit count). When AI can’t run (not signed in, email not confirmed, credits or the daily allowance used up, fair-use pause, AI down), you still get the rules and the cut, and aiError says why in plain words. aiUsed: true marks AI-written text; keepMeaning in the response says what applied. When an amazon-title fix fits, the response also has itemHighlights: what didn’t fit the 75-character name, ready for Amazon’s 125-character Item Highlights field, plus every keyword that would be lost.

text string
The copy to fix.
surfaceId string
One screen id (or customSurface inline).
keepMeaning boolean
Default true. Rewrite with AI when the rules can’t make it fit. false: rules, then a cut.
allowCut boolean
Default true (it used to be false, so text that couldn’t fit came back as "over"). Send false to keep that: nothing is cut and a fix that doesn’t fit comes back as "over".
useAi boolean
Older name, still works. Used only when keepMeaning is not sent: false means keepMeaning false; true also means keepMeaning true, but returns an error (as before) when AI can’t run.
Response (shortened) · json
{
"data": {
"text": "Acme Trail Running Shoes for Men, Lightweight Breathable Mesh",
"status": "cut",
"aiUsed": false,
"after": { "fits": true, "used": 61, "max": 75 },
"itemHighlights": {
"text": "Non-Slip Sole, Size 10",
"chars": 22,
"max": 125,
"keywords": { "total": 12, "kept": 12, "lost": [] },
"dropped": [{ "text": "Best Seller", "reason": "promo" }]
}
}
}

POST /labels/move · labels first

Moves the ad labels and risk warnings in a text to the start, after any that are already there, and changes nothing else. Kind is ad, fca-warning or fca-short-warning; Truncly only recognises the exact wording it knows. /fix does the same on a social screen where a label would be cut off, and never cuts inside a label or a risk warning; when a risk warning is longer than the screen shows, the fix says so instead of cutting it. Bulk rows for social screens carry labels too, and the download gets a truncly_labels column. This helps you check. It isn’t legal advice. Free to call, rate limited like /fit.

text string
Up to 10,000 characters.
Request · bash
curl -X POST https://truncly.app/api/v1/labels/move \
-H "Authorization: Bearer tr_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "text": "Our new shoe is here, built for long days. #ad" }'
​
# { "data": { "text": "#ad Our new shoe is here, built for long days.", "moved": ["#ad"], "changed": true, "labels": 1 } }

POST /personalization · longest name

Checks copy that has name tags ({{ first_name }}, *|FNAME|*, {{ contact.firstname }}, %%FirstName%%, a Braze or plain {{name}}) against the widest names of a real list, on every screen you pick (all screens when you send none). For each tag and screen it says the share of the list that fits, the longest name in letters before the screen cuts it, and an example. SMS screens also give parts, encoding and cost at the longest name, and the name that makes the message Unicode. Send only the widest values (about 20, with how often each appears) plus the size of the list: up to 6 tags, 40 values of 200 characters each. Nothing is stored or logged. Free to call, rate limited like /fit, and an API key call counts as one API call.

text string
Up to 10,000 characters, with at least one name tag.
tags object[]
{ tag, values, total?, distinct?, blank?, typical? }. tag is the tag as written, like "{{ first_name }}". values is strings or { value, count }. total is the number of names in the whole list, blank the number of empty rows.
surfaceIds string[]
Screen ids from GET /surfaces. Omit to check all of them.
sms object
{ pricePerSegment, recipients, maxSegments } for the SMS cost.
Request · bash
curl -X POST https://truncly.app/api/v1/personalization \
-H "Authorization: Bearer tr_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "text": "{{ first_name }}, your free shipping ends tonight", "surfaceIds": ["gmail-iphone-subject"], "tags": [{ "tag": "{{ first_name }}", "total": 1200, "values": [{ "value": "Maximilian", "count": 3 }, { "value": "Sam", "count": 90 }] }] }'
​
# { "data": { "results": [{ "surfaceId": "gmail-iphone-subject", "status": "some-cut", "tags": [{ "label": "first name", "fitPercent": 99, "cutAbove": 9, "example": "Maximilian" }] }] } }

POST /sms/saver · SMS money saver

Two calls. /sms/saver/measure says how many parts a text sends as and what it costs for each country you list (people and your own price per part, in EUR, USD or GBP). /sms/saver/rewrite rewrites the text into standard SMS characters and returns the same costs before and after. Quick fixes come first (curly quotes, long dashes, special spaces). Then, with keepMeaning (on by default), AI rewords what is left and never spells a word without its accent. AI uses one credit per started 1,000 characters, only when the quick fixes aren’t enough. When AI can’t run you still get the quick fixes, and aiError says why. A rewrite only comes back when it uses fewer parts, keeps every number, link, key detail and mustKeep phrase, and holds no character outside the set. table is optional: es, pt or tr allow those letters (check your SMS provider supports this). No provider prices are built in.

text string
Up to 1,600 characters.
countries object[]
[{ country: "DE", recipients: 10000, pricePerPart: 0.05 }], up to 20, each country once.
currency string
EUR (default), USD or GBP.
table string
standard (default), es, pt or tr.
keepMeaning boolean
Rewrite only. Default true. false: quick fixes only.
mustKeep string[]
Rewrite only. Phrases that must stay exactly as written (max 10).
Request · bash
curl -X POST https://truncly.app/api/v1/sms/saver/rewrite \
-H "Authorization: Bearer tr_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "text": "Só hoje: não perca 20% na loja online. Use o código SAVE20 até sexta e leve grátis a entrega para você e sua família, sem pagar nada a mais no pedido.", "countries": [{ "country": "PT", "recipients": 10000, "pricePerPart": 0.05 }] }'
​
# { "data": { "status": "reworded", "before": { "parts": 3 }, "after": { "parts": 1 }, "money": { "saved": { "per1000": 100 } } } }

POST /kits · fit everywhere

One message in, a fitted version for up to 12 screens out. Counts one API check per screen. Takes the same keepMeaning, allowCut and useAi as /fix (keepMeaning is on by default); each screen only uses an AI credit when the rules can’t fit it. The response has aiNote, one plain line saying why AI stopped or didn’t run when a screen was cut instead, plus aiExhausted and aiLimit when a credit, daily or fair-use limit stopped it.

text string
The copy to fit.
surfaceIds string[]
Up to 12 screen ids.
keepMeaning boolean
Default true. false: rules, then a cut, no AI.
Request · bash
curl -X POST https://truncly.app/api/v1/kits -H "Authorization: Bearer tr_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"text":"Spring sale: 30% off everything until Sunday","surfaceIds":["apple-mail-iphone-subject","ios-push-title","sms"]}'

POST /languages/check · other languages

mode "estimate" predicts whether the text still fits after translation (one API check, no AI credit). mode "translate" returns AI translations measured against the limit (one AI credit per language that fits, per started 1,000 characters; misses are free).

Request · bash
curl -X POST https://truncly.app/api/v1/languages/check -H "Authorization: Bearer tr_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"text":"Acme Trail Running Shoes for Men","surfaceId":"amazon-title","languages":["de","fr"],"mode":"estimate"}'

POST /campaigns/check · one offer, every channel

Checks the same offer as it goes out on several channels. Each piece is measured on its own screens, then the pieces are compared: a promo code, price, discount or deadline that is cut off on a screen, that one channel leaves out, or that differs between channels. The answer is { channels, issues }. channels has one entry per channel you sent (email, sms, push, whatsapp, ads) with its fields, the status on every screen and the details found; issues lists each problem with a severity (error or warning), a plain message and the channels involved. Issue codes look like code-cut, price-missing, deadline-mismatch, over-limit or sms-parts. Blank fields are ignored. Free to call, counts as one API check, rate limited like /fit. WhatsApp template limits: header 60, message 1,024, footer 60, button 25, from Meta’s documentation.

fields object[]
Up to 11 of { field, text }, each field once, text up to 2,000 characters. Fields: email-subject, email-preheader, sms, push-title, push-body, whatsapp-header, whatsapp-body, whatsapp-footer, whatsapp-button, ad-headline, ad-text.
Request · bash
curl -X POST https://truncly.app/api/v1/campaigns/check \
-H "Authorization: Bearer tr_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "fields": [
{ "field": "email-subject", "text": "30% off with code SAVE20 until Sunday" },
{ "field": "sms", "text": "25% off with code SAVE25. Ends Sunday" }
] }'
​
# { "data": { "channels": [ ... ], "issues": [ { "code": "code-mismatch", "severity": "error", "message": "Your promo code isn’t the same everywhere. ...", "channels": ["email", "sms"] } ] } }

GET /extension/session · Chrome extension

Used by the Truncly Chrome extension after you paste a key. It answers with your plan and how many AI rewrites are left. It needs an API key (a browser session gets 401), isn’t counted as an API check, and answers 402 plan_required below Pro. The extension checks text with POST /fit and fixes it with POST /fix and keepMeaning, like any other caller.

Request · bash
curl https://truncly.app/api/v1/extension/session -H "Authorization: Bearer tr_live_YOUR_KEY"
​
# { "data": { "allowed": true, "plan": { "id": "pro", "name": "Pro" }, "ai": { "available": true, "left": 1500, "note": null } } }

GET /sheets/session and POST /sheets/check · Google Sheets add-on

Used by the Truncly Google Sheets add-on (Pro and up); open to anyone with a key. GET /sheets/session says whether the key may use the add-on, your plan, API checks used and left and AI rewrites left, and is not metered. Below Pro it answers plan_required with a plain message. POST /sheets/check measures up to 100 texts against one screen in one request and only checks: no AI, no credits. Each result has status, over (characters that don’t show), used and max (in the screen’s unit), clean and problems. It counts one API check per text that isn’t blank, and its rate limit is per key. Both need an API key, never a browser session.

surfaceId string
One screen id from GET /surfaces.
texts string[]
1 to 100 texts, each up to 10,000 characters.
Request · bash
curl -X POST https://truncly.app/api/v1/sheets/check -H "Authorization: Bearer tr_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"surfaceId":"gmail-iphone-subject","texts":["Spring sale: 30% off everything until Sunday"]}'
​
# { "data": { "surfaceId": "gmail-iphone-subject", "results": [{ "status": "cut", "over": 12, "used": 274, "max": 236, "unit": "px", "clean": false, "problems": 0 }] } }

POST /hooks · webhooks and Zapier

Scale and Agency, with an API key only (other plans get plan_required). Truncly posts a small JSON message to the https address you subscribe when a bulk file is done or has failed; canceled files are not sent. The Zapier app uses these routes, and you can too. POST /hooks with { event: "bulk.finished", targetUrl } answers 201 with { id, event, targetHost, secret }: the secret is shown once. GET /hooks lists your hooks (host only, never the secret). DELETE /hooks/:id removes one (204, or 404 when it is gone). GET /hooks/sample returns the newest finished files in the same shape as the post, or one example when you have none. The address must be https on port 443 with a host name that resolves to a public address; private, loopback and link-local addresses (10.x, 127.x, 169.254.x, 192.168.x and the like), numbers instead of names, and redirects are refused. Each post carries X-Truncly-Signature: sha256=HMAC-SHA256 of "timestamp.body" with your secret, X-Truncly-Timestamp, X-Truncly-Event and X-Truncly-Delivery. Answer with any 2xx. A 5xx, 429 or time-out is retried up to 5 times (after 1, 3, 9 and 27 minutes); 410 removes the hook at once; other answers are not retried. After 3 failed deliveries in a row a hook is disabled (disabled: true in the list); subscribing the same address again turns it back on. You can have 10 active hooks. Also for Zapier: GET /zapier/me (connection test), POST /zapier/check and POST /zapier/fix run /fit and /fix for Scale and up, with keepMeaning on unless you send false.

event string
Only "bulk.finished".
targetUrl string
https address on port 443 that answers 2xx (up to 2,000 characters).
What we post (no row text, no emails, no keys) · json
{
"id": "9f1c…",
"event": "bulk.finished",
"fileId": "9f1c…",
"filename": "spring-titles.csv",
"status": "done",
"error": null,
"totalRows": 120,
"processedRows": 120,
"counts": { "fits": 80, "fixed": 30, "rewritten": 8, "cut": 2, "over": 0, "empty": 0, "edited": 0, "keywordsLost": 0 },
"finishedAt": "2026-01-01T09:00:00.000Z",
"appUrl": "https://app.truncly.app/bulk/9f1c…",
"downloadUrl": null
}

Errors & limits

Errors use an HTTP status and a stable code, so you can act on the code. The limits that apply to your calls are listed after the table.

StatusCodeWhen
400validation_error, bad_requestThe request is missing something or has a value we can’t use.
401unauthorizedThe key is missing, wrong or no longer active.
402quota_exceededYour monthly checks or AI credits, the daily AI allowance or the fair-use limit are used up.
402plan_requiredYour plan doesn’t include this feature.
403forbiddenNot allowed for this account, for example AI before the email is confirmed.
404not_foundThere is nothing at that address or id.
409conflictThe request clashes with what is already saved.
413payload_too_largeThe request is bigger than we accept.
429rate_limitedToo many requests from one IP address in a minute. A Retry-After header says when to try again.
500internal_errorSomething went wrong on our side.
503service_unavailableA service we rely on isn’t available right now.
  • Monthly checksAPI checks count against your monthly plan quota.
  • Monthly AI creditsAI rewrites use your monthly credits first (Free 50, Pro 1,500, Scale 10,000, Agency 30,000), then any credit-pack credits. Running out returns quota_exceeded.
  • Daily AI allowanceFree 25, Pro 300, Scale 1,500, Agency 5,000 a day. Bulk files skip it. Going over returns quota_exceeded with details.window "day" and details.resetsAt (midnight UTC).
  • Fair useEach account also has a monthly AI fair-use limit based on real AI cost. Reaching it returns quota_exceeded with details.reason "fair_use" and details.resetsAt (the 1st).
  • Rate limits per IP/fit 180 a minute. /fix, /kits and /languages/check 60 a minute. Everything else 600 a minute.
  • Rate-limit headersEvery response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. A 429 also sends Retry-After.
  • VersioningThe API is versioned in the path. Breaking changes ship as /api/v2.