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 · bashcurl 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.
textstring- Up to 10,000 characters.
surfaceIdsstring[]- Screen ids from GET /surfaces, e.g. "amazon-title".
mustKeepstring[]- Phrases that must stay visible (max 10).
brandstring- For marketplace titles that should start with the brand.
customSurfacesobject[]- Inline limits: { name, limit: { kind: "chars", max } | { kind: "pixels", box }, enforcement, ellipsis }.
smsobject- { pricePerSegment, recipients, maxSegments } for cost estimates.
Request · bashcurl -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.
textstring- The copy to fix.
surfaceIdstring- One screen id (or customSurface inline).
keepMeaningboolean- Default true. Rewrite with AI when the rules can’t make it fit. false: rules, then a cut.
allowCutboolean- 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".
useAiboolean- 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.
textstring- Up to 10,000 characters.
Request · bashcurl -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.
textstring- Up to 10,000 characters, with at least one name tag.
tagsobject[]- { 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.
surfaceIdsstring[]- Screen ids from GET /surfaces. Omit to check all of them.
smsobject- { pricePerSegment, recipients, maxSegments } for the SMS cost.
Request · bashcurl -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.
textstring- Up to 1,600 characters.
countriesobject[]- [{ country: "DE", recipients: 10000, pricePerPart: 0.05 }], up to 20, each country once.
currencystring- EUR (default), USD or GBP.
tablestring- standard (default), es, pt or tr.
keepMeaningboolean- Rewrite only. Default true. false: quick fixes only.
mustKeepstring[]- Rewrite only. Phrases that must stay exactly as written (max 10).
Request · bashcurl -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.
textstring- The copy to fit.
surfaceIdsstring[]- Up to 12 screen ids.
keepMeaningboolean- Default true. false: rules, then a cut, no AI.
Request · bashcurl -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 · bashcurl -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.
fieldsobject[]- 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 · bashcurl -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 · bashcurl 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.
surfaceIdstring- One screen id from GET /surfaces.
textsstring[]- 1 to 100 texts, each up to 10,000 characters.
Request · bashcurl -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.
eventstring- Only "bulk.finished".
targetUrlstring- 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.
| Status | Code | When |
|---|---|---|
| 400 | validation_error, bad_request | The request is missing something or has a value we can’t use. |
| 401 | unauthorized | The key is missing, wrong or no longer active. |
| 402 | quota_exceeded | Your monthly checks or AI credits, the daily AI allowance or the fair-use limit are used up. |
| 402 | plan_required | Your plan doesn’t include this feature. |
| 403 | forbidden | Not allowed for this account, for example AI before the email is confirmed. |
| 404 | not_found | There is nothing at that address or id. |
| 409 | conflict | The request clashes with what is already saved. |
| 413 | payload_too_large | The request is bigger than we accept. |
| 429 | rate_limited | Too many requests from one IP address in a minute. A Retry-After header says when to try again. |
| 500 | internal_error | Something went wrong on our side. |
| 503 | service_unavailable | A 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.