← SMS Desk / API
Tokens

Drive SMS Desk from your own code

Everything the web page's two paid lanes do is available over HTTP. You send one JSON object with a task (review or draft) and a facts string, and the model replies with one JSON object: a verdict, per-message rewrites and HELP and STOP replies for a review, or a complete flow with an opt-in disclosure for a draft.

Before the first call, note this: the model never counts characters or segments. The page's free check runs entirely in your browser, in smskit.js: it reads the flow, measures every message as it is billed (GSM-7 or UCS-2, segments, merge tags filled at their assumed width) and raises findings against the rules in smsrules.js. That result is what the page sends as facts. A direct API caller has no page, so you build facts yourself, ideally with the same two files (see building the facts).

Base URL and the envelope

Every endpoint below lives under https://api.skillsafe.ai/v1/app-api. A success carries its payload in data; a failure has a non-2xx HTTP status and an error object:

HTTP 2xx   { "data":  { ... } }
HTTP 4xx   { "error": { "code": "...", "message": "...", "details": { ... } } }

This is how the vendored SDK (sdk.js) reads every response: when the status is not 2xx it raises an error carrying the HTTP status and error.code, error.message and error.details; otherwise it returns data. So branch on the HTTP status. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer YOUR_TOKEN. No slug header is sent: the token was issued for this app.

The input object is the request body. The SDK posts JSON.stringify(input) as the body of /estimate, /run and /run-stream; there is no wrapper. The page guards itself with Smskit.mustBeObject, which throws unless the input is a plain object. Do the same.

Error codes

Only the statuses the SDK or the page handle by name are listed. For anything else, log error.code and error.message as given.

statuswhat the page doeswhat to do
401Treats it as "Your session expired": it forgets the signed-in user and asks for a new sign-in.Get a fresh token from the Tokens page.
402Shows "Not enough credits for this run" with a top-up link. The run button is disabled beforehand whenever the balance is below min_credits from /estimate, so the page does not submit into a 402.Compare credits from /me with min_credits before you run; top up.
other non-2xxShows "The run failed: " followed by error.message.Read error.code and error.message, fix the request.
SSE errorOn /run-stream, a failure after the stream opened arrives as an error event; the SDK throws with its message, code and job_id.Log all three; the job_id identifies the failed job.

The task field: two lanes

One system prompt serves both lanes, routed on task. The body has two required string fields and two optional ones:

fieldtypemeaning
taskstring, required"review" or "draft".
factsstring, requiredA JSON-encoded string, not an object: the page sends JSON.stringify(facts). Its contents depend on the lane (below).
questionstring, optionalA free-text question for the model, answered in summary. The page trims it, sends it only when it is not empty and cuts it to 600 characters.
retry_notestring, optionalNever sent on a normal run. The page adds it only on its one automatic retry after a reply it could not parse, asking for a single valid JSON object. You may use it the same way.

review: check and rewrite a pasted flow

The marketer pastes an existing flow. The browser's free check measures and lints it, and facts is exactly what that check computed (Smskit.buildFacts). The model decides whether the flow can be sent, answers every block and warn finding, rewrites the messages that need it and writes HELP and STOP replies. Its verdict may be stricter than the browser's verdict_floor, never looser.

facts fieldcontent
brandThe brand name the texts are signed with.
jurisdictionsArray drawn from "US", "CA", "EU/UK", "AU".
default_kindThe kind assumed for a message that does not name one (the page offers "marketing" or "transactional").
segment_budgetThe most segments a message should take (1 to 10).
disclosed_per_month, list_size, price_per_segmentNumbers, or null when not given.
send_windowFor example "09:00-20:00".
recipient_zonesIANA time zones the recipients are in.
merge_widthsObject of merge tag name to assumed width, for example {"first_name": 12}.
messages[]One per message: id ("M1"), label, kind (opt_in, marketing, transactional, otp, help, stop), kind_rule, timing, body, encoding (GSM-7 or UCS-2), chars, units, segments, worst_case_segments, merge_tags, links, flags (ids of its findings).
flags[]One per finding: id ("X1"), code, severity (block, warn, info), message (a message id, or null for the whole flow), rule (a short title), detail.
countsmessages, marketing, segments, segments_worst, ucs2, block, warn, info.
cost{per_recipient, per_recipient_worst, total, total_worst} when a price is given (total fields null without a list size), otherwise null.
verdict_floorblocked when any block flag exists, fix_first when any warn flag exists, otherwise ready.
notesUp to 10 reading notes, plus one when messages were dropped.

Limits the page applies while building these facts: it reads at most 60,000 characters of paste and the first 40 messages, and a body longer than 900 characters is sent with its middle cut and marked ([... N characters cut from the middle ...]). The counts are always measured on the full body.

draft: write a flow from a brief

The marketer fills in a brief and facts is that brief (Smskit.readBrief). The page will not build a draft input until the brand is set, flow_type is one of the nine listed values and business is at least 12 characters.

facts fieldcontent
brandThe brand name, up to 60 characters.
businessWhat they sell and to whom, up to 600 characters.
flow_typewelcome, abandoned_cart, browse_abandonment, post_purchase, win_back, promotional, transactional, back_in_stock or appointment_reminder.
audience, offerUp to 300 characters each; offer may be empty.
merge_tagsUp to 8 tag names, the only tags the draft may use (default first_name, link).
jurisdictions, segment_budgetAs in review.
gsm_only, include_opt_inBooleans, both true unless set to false.
disclosed_per_monthA number, or null.
notesFree text, up to 600 characters.

The reply contract

A finished job carries the model's reply as text in output.output. The system prompt asks for exactly one JSON object with no prose and no code fences, and arrays that are present even when empty. The page parses it by trimming a code fence if there is one and taking the text from the first { to the last }.

review reply:

{
  "lane": "review",
  "verdict": "ready" | "fix_first" | "blocked",
  "headline": "...",
  "messages": [
    {
      "id": "M1",
      "verdict": "ok" | "fix" | "block",
      "issues": [ { "flag": "X2", "note": "..." } ],
      "rewrite": "full replacement text, or an empty string",
      "why": "...",
      "target_segments": 1,
      "target_encoding": "GSM-7" | "UCS-2"
    }
  ],
  "flow_notes": [ { "topic": "timing", "flags": ["X9"], "note": "..." } ],
  "a2p_samples": ["M1"],
  "help_reply": "...",
  "stop_reply": "...",
  "summary": "..."
}

How the page reads it (Recon.normalizeReview): a verdict outside the three values is read as blocked; a message whose id is not M plus digits is dropped; an unknown message verdict is read as fix; issue flag ids must be X plus digits; flow_notes topics are timing, frequency, sequence, offer, compliance, registration, cost or copy (anything else is read as copy); a reply with no messages array, no headline and no summary is rejected.

draft reply:

{
  "lane": "draft",
  "flow_name": "...",
  "trigger": "...",
  "headline": "...",
  "messages": [
    {
      "label": "Send 1",
      "kind": "opt_in" | "marketing" | "transactional",
      "delay": "+30m",
      "body": "...",
      "purpose": "...",
      "target_segments": 1
    }
  ],
  "opt_in_disclosure": "...",
  "help_reply": "...",
  "stop_reply": "...",
  "tests": ["..."],
  "notes": ["..."],
  "summary": "..."
}

How the page reads it (Recon.normalizeDraft): messages get ids M1, M2, ... by position; a message with an empty body is dropped; label is cut to 80 characters and delay to 40; an unknown kind is read as marketing; a reply with no messages, no headline and no summary is rejected.

What the page adds, and what you get

The page re-measures and re-checks every text the model writes. For a review it confirms every message was reviewed, that every block and warn finding is answered where it belongs, measures each rewrite again (encoding, segments, against the target_segments and target_encoding the model claimed and against the segment budget), lints it again with the same rules, checks merge tags were kept, checks every count written in prose against the measured counts, holds the verdict to verdict_floor, and checks the HELP and STOP replies. For a draft it measures and lints every drafted message, checks gsm_only, the merge-tag list, the delays, the opt-in message of a welcome flow, the disclosure's contents and the replies. Each disagreement is shown to the marketer.

An API caller gets the raw reply without that reconciliation. The job returns only the model's text. If you need the same checks, load smsrules.js, smskit.js and recon.js (in Node, set global.SmsRules and global.Smskit first, as below), parse the reply with Recon.parseResult(text), normalize it with Recon.normalizeReview or Recon.normalizeDraft, and call Recon.reconcileReview(res, facts) or Recon.reconcileDraft(res, facts) with facts as the parsed object. Each returns {items, statements, disagreements} among other fields; every item has kind, text and ok.

Building the facts

smsrules.js, smskit.js and recon.js are the files the page loads from https://sms-desk.skillsafe.ai/. They are plain functions with no DOM, and each also exports itself through module.exports, so Node can use them directly. smskit.js looks for the rules on global.SmsRules. This builds both worked example bodies below, byte for byte:

// Node 18+, with smsrules.js and smskit.js saved next to this file.
global.SmsRules = require("./smsrules.js");
const Smskit = require("./smskit.js");

// review: analyze the flow text, then buildFacts
const flow = "[Shipped | +0 | transactional]\n" +
  "Brackwold: Your order {{ order_number }} has shipped. Track it: brackwold.com/track Reply HELP for help, STOP to opt out.\n\n" +
  "[Review ask | +5d | marketing]\n" +
  "Brackwold: How are the new boots, {{ first_name }}? Tell us in 30 seconds: brackwold.com/review Reply STOP to opt out\n\n" +
  "[HELP reply | help]\n" +
  "Brackwold: Help at brackwold.com/help or 1-800-555-0142. Msg & data rates may apply. Reply STOP to cancel.\n\n" +
  "[STOP reply | stop]\n" +
  "Brackwold: You're unsubscribed and will not receive more messages. Reply HELP for help.\n";
const P = Smskit.analyze(flow, {
  brand: "Brackwold", jur: { us: true }, segment_budget: "1",
  widths: "order_number=10, first_name=12", window_start: "09:00", window_end: "20:00",
});
const review = Smskit.buildInput("review", Smskit.buildFacts(P), "");

// draft: readBrief returns { errors, facts }; run only when errors is empty
const brief = Smskit.readBrief({
  brand: "Tansyra Skin", jur: { us: true, ca: true }, segment_budget: "1", per_month: "4",
  business: "Tansyra Skin sells fragrance-free skincare for sensitive skin, online only, to adults in the US and Canada.",
  flow_type: "win_back",
  audience: "Customers whose last order was 90 or more days ago and who opted in to texts.",
  offer: "20% off the next order with code COMEBACK20, valid for 7 days from the first win-back text.",
  merge_tags: "first_name, link", gsm_only: true, include_opt_in: false,
  notes: "Friendly, not pushy. The link tag goes to a personalised reorder page.",
});
if (brief.errors.length) throw new Error(brief.errors.join(" "));
const draft = Smskit.buildInput("draft", brief.facts, "");

require("node:fs").writeFileSync("review.json", JSON.stringify(review));
require("node:fs").writeFileSync("draft.json", JSON.stringify(draft));

buildInput is where facts becomes a string and where an empty question is left out. Building facts in another language is possible, but every count and finding must then match what smskit.js would compute, because the model is told those counts are authoritative.

Worked examples: one body per lane

These are the exact bodies the page sends for its two built-in examples (the Node snippet above rebuilds them). The outer object is pretty-printed here; facts stays one JSON string. Save them as review.json and draft.json: the steps below read the body from a file and send its bytes unchanged.

review: Brackwold shipping and review ask

Four messages, two info findings (X1 on M2, X2 for the whole flow) and verdict_floor ready. No question, so none is sent.

{
  "task": "review",
  "facts": "{\"brand\":\"Brackwold\",\"jurisdictions\":[\"US\"],\"default_kind\":\"marketing\",\"segment_budget\":1,\"disclosed_per_month\":null,\"list_size\":null,\"price_per_segment\":null,\"send_window\":\"09:00-20:00\",\"recipient_zones\":[\"America/New_York\",\"America/Chicago\",\"America/Denver\",\"America/Phoenix\",\"America/Los_Angeles\",\"America/Anchorage\",\"Pacific/Honolulu\"],\"merge_widths\":{\"order_number\":10,\"first_name\":12},\"messages\":[{\"id\":\"M1\",\"label\":\"Shipped\",\"kind\":\"transactional\",\"kind_rule\":\"header\",\"timing\":\"T+0\",\"body\":\"Brackwold: Your order {{ order_number }} has shipped. Track it: brackwold.com/track Reply HELP for help, STOP to opt out.\",\"encoding\":\"GSM-7\",\"chars\":113,\"units\":113,\"segments\":1,\"worst_case_segments\":1,\"merge_tags\":[\"order_number\"],\"links\":[\"brackwold.com/track\"],\"flags\":[]},{\"id\":\"M2\",\"label\":\"Review ask\",\"kind\":\"marketing\",\"kind_rule\":\"header\",\"timing\":\"T+5d\",\"body\":\"Brackwold: How are the new boots, {{ first_name }}? Tell us in 30 seconds: brackwold.com/review Reply STOP to opt out\",\"encoding\":\"GSM-7\",\"chars\":113,\"units\":113,\"segments\":1,\"worst_case_segments\":2,\"merge_tags\":[\"first_name\"],\"links\":[\"brackwold.com/review\"],\"flags\":[\"X1\"]},{\"id\":\"M3\",\"label\":\"HELP reply\",\"kind\":\"help\",\"kind_rule\":\"header\",\"timing\":\"unknown\",\"body\":\"Brackwold: Help at brackwold.com/help or 1-800-555-0142. Msg & data rates may apply. Reply STOP to cancel.\",\"encoding\":\"GSM-7\",\"chars\":106,\"units\":106,\"segments\":1,\"worst_case_segments\":1,\"merge_tags\":[],\"links\":[\"brackwold.com/help\"],\"flags\":[]},{\"id\":\"M4\",\"label\":\"STOP reply\",\"kind\":\"stop\",\"kind_rule\":\"header\",\"timing\":\"unknown\",\"body\":\"Brackwold: You're unsubscribed and will not receive more messages. Reply HELP for help.\",\"encoding\":\"GSM-7\",\"chars\":87,\"units\":87,\"segments\":1,\"worst_case_segments\":1,\"merge_tags\":[],\"links\":[],\"flags\":[]}],\"flags\":[{\"id\":\"X1\",\"code\":\"merge_ucs2_risk\",\"severity\":\"info\",\"message\":\"M2\",\"rule\":\"A merged name can switch encoding\",\"detail\":\"A merged value with one non-GSM letter (a name such as Zoë) would switch this message to UCS-2: 2 segment(s) instead of 1.\"},{\"id\":\"X2\",\"code\":\"trigger_timing\",\"severity\":\"info\",\"message\":null,\"rule\":\"Triggered sends need a quiet-hour hold\",\"detail\":\"1 marketing message(s) go out on a delay after the trigger (M2 T+5d), so the send hour depends on when the trigger fires; quiet hours must be enforced by the platform.\"}],\"counts\":{\"messages\":4,\"marketing\":1,\"segments\":4,\"segments_worst\":5,\"ucs2\":0,\"block\":0,\"warn\":0,\"info\":2},\"cost\":null,\"verdict_floor\":\"ready\",\"notes\":[]}"
}

The reply follows the review contract: one entry in messages for each of M1 to M4, in order, with verdict no looser than ready. Only block and warn findings must be answered, so X1 and X2 may be answered or left alone.

draft: Tansyra Skin win-back

A win_back brief with an offer, gsm_only true, a one-segment budget and two allowed merge tags.

{
  "task": "draft",
  "facts": "{\"brand\":\"Tansyra Skin\",\"business\":\"Tansyra Skin sells fragrance-free skincare for sensitive skin, online only, to adults in the US and Canada.\",\"flow_type\":\"win_back\",\"audience\":\"Customers whose last order was 90 or more days ago and who opted in to texts.\",\"offer\":\"20% off the next order with code COMEBACK20, valid for 7 days from the first win-back text.\",\"merge_tags\":[\"first_name\",\"link\"],\"jurisdictions\":[\"US\",\"CA\"],\"segment_budget\":1,\"gsm_only\":true,\"disclosed_per_month\":4,\"include_opt_in\":false,\"notes\":\"Friendly, not pushy. The link tag goes to a personalised reorder page.\"}"
}

The reply follows the draft contract. The system prompt asks for two to five messages in send order, each body starting with Tansyra Skin:, only the first_name and link tags, and two to four tests.

1. A tiny client

One helper that sends an already-serialised JSON body with Content-Type: application/json, adds Authorization: Bearer ... when a token is set (the SDK does the same: no token, no header), unwraps data and raises on a non-2xx status with error.code and error.message. It takes the body as a string so a run can derive its key from the exact bytes it sends (step 5). The later steps assume this helper is in scope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://sms-desk.skillsafe.ai/tokens.html

# api METHOD PATH [BODY_FILE] [extra curl args...]
# Prints the response body; exits non-zero (curl --fail-with-body) on a 4xx/5xx.
api() {
  local method="$1" path="$2" body="${3:-}"
  shift 2; [ $# -gt 0 ] && shift
  local auth=(); [ -n "$TOKEN" ] && auth=(-H "Authorization: Bearer $TOKEN")
  if [ -n "$body" ]; then
    curl -sS --fail-with-body -X "$method" "$BASE$path" "${auth[@]}" \
      -H "Content-Type: application/json" --data-binary "@$body" "$@"
  else
    curl -sS --fail-with-body -X "$method" "$BASE$path" "${auth[@]}" \
      -H "Content-Type: application/json" "$@"
  fi
}

2. Get a token

The easiest route is the Tokens page. It shows the token this browser already holds for sms-desk, with Copy token and Copy shell export buttons, and a Sign in with SkillSafe button for a personal token. It reads the same storage the app uses, so you never need a developer tool.

There are two kinds of token. A guest token is what the page starts with: the SDK mints one with POST /guest and the body {"slug":"sms-desk"}, sent without an Authorization header, and reads data.token (and data.guest_id) from the reply. The page then calls /me and /estimate with it. Reviewing or drafting is metered: unless /estimate reports sponsor_enabled, the page tells a guest "This review is metered, so it needs an account" and asks them to sign in. A personal token comes from signing in on the Tokens page and bills your own balance. Treat it like a password.

# Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html,
# press "Copy token" and paste it into TOKEN.
#
# Guest token, minted from the command line (no Authorization header):
TOKEN=$(curl -sS -X POST "$BASE/guest" -H "Content-Type: application/json" \
  -d '{"slug":"sms-desk"}' | jq -r '.data.token')

3. Check the session and the balance

GET /me returns {subject_type, subject_id, credits, profile?}. The page treats subject_type "user" as signed in and reads credits to show the balance and to gate the run button (step 4). Credits convert to dollars at 10,000 credits per dollar, as the page displays them.

api GET /me | jq '.data | {subject_type, credits}'

4. Price it (free)

POST /estimate takes the same body you will run and returns the worst-case credit cost of that run. It charges nothing and creates no job, so call it as often as you like: the page re-prices on every edit, separately for each lane. The fields the page reads or displays:

fieldhow the page uses it
hold_creditsShown as "reserves N credits ... for the review" (or draft). It is a reservation, not the price: the page adds "you are charged only for what the run actually uses".
min_creditsShown as "minimum N". A signed-in balance below it disables the run button; a balance at or above it but below hold_credits gets a warning that the run "may be cut short".
sponsor_enabledWhen true, the page lets a guest run instead of asking them to sign in.
model, model_aliasThe model the run would use and its alias.
markup_bpsThe markup applied to the model cost, in basis points.

This app declares an input schema with task and facts required, so the estimate response also reports input_checked and a warnings array naming missing or unknown fields; warnings are advisory and do not stop a run.

Observed on this app on 2026-09-28: a well-formed body returned input_checked: true and an empty warnings array; an empty object returned warnings for the missing task and facts but still returned a hold; a body wrapped as {"input": {...}} added a warning for the unknown field input; and a body that is not a JSON object (a bare string) was rejected with HTTP 400 validation_error. The estimate reported model gpt-5.6-terra, model_alias gpt-terra and markup_bps 1000.

The page shows any warnings next to the price as "input warnings". Treat one as a bug in your body. The snippets below read review.json from the worked examples; use draft.json for the other lane.

# The body must be an object with string task and facts.
jq -e '(.task == "review" or .task == "draft") and (.facts | type == "string")' review.json > /dev/null

api POST /estimate review.json \
  | jq '.data | {hold_credits, min_credits, sponsor_enabled, model, warnings}'

5. Run it, then poll the job

POST /run with the same body starts the run and returns {"job_id": "..."}. Then poll GET /jobs/{job_id} until status is succeeded or failed; the SDK polls once a second and gives up after 180 seconds. The finished job carries the model's reply as text in output.output, plus charged_credits, truncated and, on failure, error. When truncated is true the page tells the marketer the reply "was cut short by the available balance" and shows only the sections that arrived.

Send an Idempotency-Key header on every run; it is the header the SDK sets on /run and /run-stream. The page derives it from the lane and the input: sms-desk:<task>:<hash of JSON.stringify(input)>:a<attempt>, where the hash is the page's own 32-bit inputHash. For the two worked examples that gives sms-desk:review:fc1b81d4-b0b:a1 and sms-desk:draft:d391f4b6-292:a1. The page's stated purpose is that a retry of the same input reuses the same key, so "a network blip never double-bills". Its one automatic reformat retry keeps the hash of the original input (without retry_note) and moves to :a2, and the page announces it as "one extra run". The JavaScript tab below uses the page's exact hash; the other tabs use the first 16 hex digits of a SHA-256 of the body bytes, which is just as deterministic. Change the attempt suffix only when you mean a new run.

TASK=$(jq -r '.task' review.json)
KEY="sms-desk:$TASK:$(shasum -a 256 review.json | cut -c1-16):a1"

JOB=$(api POST /run review.json -H "Idempotency-Key: $KEY" | jq -r '.data.job_id')
echo "job $JOB"

for i in $(seq 1 180); do
  api GET "/jobs/$JOB" > job.json
  STATUS=$(jq -r '.data.status' job.json)
  if [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ]; then break; fi
  sleep 1
done

jq '.data | {status, charged_credits, truncated, error}' job.json
jq -r '.data.output.output' job.json > reply.txt     # the model's reply: one JSON object as text

6. Or stream it

POST /run-stream takes the same body and the same Idempotency-Key header. When the response Content-Type is text/event-stream, it is a stream of frames separated by a blank line; each frame has an event: line and one or more data: lines whose JSON the SDK joins and parses (frames with no data, or data that does not parse, are skipped). With any other Content-Type the SDK reads the response as a plain {data} / {error} envelope, raising on a non-2xx status and otherwise returning data; its own comment says this is the fallback on idempotent replays. The events the SDK handles:

Any other event name is ignored. Do not assemble the reply from deltas: the page's own comment is that "browsers receive ticks, not deltas, from /run-stream", so it reads the reply from done.output.output and falls back to the deltas only when that is empty. The snippets below do the same, and if the final payload carries no reply but has a job_id they fetch the job as in step 5.

TASK=$(jq -r '.task' review.json)
KEY="sms-desk:$TASK:$(shasum -a 256 review.json | cut -c1-16):a1"

# -N turns off buffering so frames print as they arrive; tee keeps a copy.
curl -sS -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data-binary @review.json | tee stream.txt

# The final payload is the data line after "event: done" (or "event: pending").
awk '/^event: *(done|pending)/ { getline; sub(/^data: ?/, ""); print }' stream.txt \
  | tail -n 1 | jq '{job_id, status, charged_credits, reply: .output.output}'

Reading the reply

text is the model's reply, which the system prompt requires to be exactly one JSON object. Parse it the way the page does (Recon.parseResult): trim, drop a leading and trailing code fence if present, and parse from the first { to the last }. Then hold it to the contract for its lane; a lane field that names the other lane is flagged by the page as a mismatch.

When the reply cannot be parsed, the page retries exactly once, with the same body plus a retry_note that says the previous reply "was not the single valid JSON object the instructions require" and asks for "ONLY the JSON object for task" of the lane, with every array present, and with the attempt suffix of the key moved on. If the retry also fails it shows the raw reply. When a run ends early and the text so far contains a {, the page closes the JSON prefix with Recon.closeJson and shows whichever sections arrived.

Then remember what the page adds: none of the re-measuring, re-linting or verdict checks happen on the server. If a rewrite or a drafted message is going to a real SMS platform, run it back through smskit.js or the page's free check first.