← Tarot Reading / API
Tokens

Driving Tarot Reading from your own code

Base URL https://api.skillsafe.ai/v1/app-api. Every response is an envelope: {"ok":true,"data":{…}} on success, {"ok":false,"error":{"code":…,"message":…}} on failure. Authorise with Authorization: Bearer <token>. Get a token from the tokens page without touching a developer console.

The one thing to understand before you write any of this: the model does not draw the cards. It is handed a finished draw and asked what it says. If you call this API, you are the one holding the deck, and the quality of your shuffle is now your responsibility — step 4 shows an unbiased one in each language.

That also means you can and should reconcile: every card_name and orientation the reading returns must match a card you dealt. A mismatch is the reading being wrong, and it is checkable precisely because you kept the draw.

What this app is for

Entertainment and structured reflection. The reading is instructed never to state or imply a future event, a probability, a timeframe or an outcome, and it declines two classes of question outright: anything a professional is licensed, regulated, qualified or insured to answer, and anything about another person's private life or fate. Those refusals come back as a populated refusal field with positions empty — handle that branch, it is a normal response and it is billed like any other run.

The first of those is deliberately not a list of three subjects. Medical, legal and financial are the common cases; the rule is who owes the answer. A veterinary treatment decision, a structural-safety question and a tax position are all refused for the same reason, and an earlier build that enumerated three subjects was beaten in one move by a question about a vet. If you are wrapping this API, do not reimplement the boundary as a keyword list — that is the exact shape that failed.

Errors

CodeHTTPWhat it means
unauthorized401Missing, malformed, expired or revoked token. A cold 401 from /me before any token exists is normal — mint a guest token and retry.
payment_required402Balance below min_credits. Call /estimate and compare against /me first; this should never surprise you.
forbidden403The token is valid but not for this app, or the operation is not allowed for a guest subject.
not_found404Unknown path, or a job_id that does not belong to this subject.
validation_error400The body did not match the input contract. error.details names the field.
rate_limited429Back off and retry with a delay. Never tight-loop.
internal5xxRetry once with the same Idempotency-Key, which is what makes the retry safe.

The input contract

One JSON object, posted as the body of /estimate, /run and /run-stream. Single lane — there is no task field, because the app is one contract with a spread parameter.

FieldTypeNotes
questionstringRequired. Up to 600 characters; the overflow belongs in context.
contextstringOptional background. Up to 3 000 characters.
spreadstringsingle (1 card), three (3), celtic (10).
framestringThree-card only: arc, stance or tension. Empty otherwise.
reversals_enabledbooleanWhether reversals were in play for this draw.
drawn_atstringISO-8601 with a Z suffix.
cardsarrayThe draw. One entry per position, in position order. Each carries slot, position_key, position_label, position_meaning, card_id, card_name, arcana, suit, suit_name, element, rank, rank_name, court, reversed, orientation, keywords, upright_keywords, reversed_keywords and image.
pattern_factsobjectArithmetic over the draw: card_count, major_count, minor_count, reversed_count, court_count, ace_count, suit_counts, element_counts, dominant_suit_name, dominant_element, absent_suits, repeated_ranks.
retry_notestringOptional. Send only when re-asking after a reply failed to parse.

The 78 card rows — ids, names, suits, elements, ranks, keywords and image lines — are served verbatim at /deck.js. That file is the corpus; copy it rather than typing card names by hand, because card_id is what reconciliation keys on.

The output contract

data.output.output is a JSON string. Parse it, and you get:

FieldTypeNotes
questionstringThe question, lightly tidied.
refusalstringEmpty on a normal reading. Non-empty means the question crossed a boundary, and positions is then [].
openingstringSets the spread up for this question.
positionsarrayOne entry per dealt card, in draw order. Each has slot, position_key, position_label, card_id, card_name, orientation, suit_name, traditionally and in_position.
patternobjectmajor_count, reversed_count, dominant_suit_name, note. Compare against your own pattern_facts.
togetherstringHow the cards speak to each other as one spread.
tensionstringThe sharpest pull between two named cards.
to_sit_withstring[]Concrete things to think about.
not_sayingstringWhat the reading is explicitly not claiming.

1. A tiny client helper

Everything below assumes this. One function, the envelope unwrapped, errors raised.

2. Get a token

A guest token costs nothing and is enough for /me and /estimate. Reading a spread is metered and needs a personal token from the tokens page.

3. Who am I, and what is the balance

/me returns exactly three fields. Compare credits against the hold from step 5 before you ever call /run.

4. Draw the cards yourself

This step has no HTTP in it, and it is the most important one on the page. Use a CSPRNG and a Fisher-Yates pass, and draw each index with rejection samplingx % 78 is biased because 78 does not divide 232, and the bias is invisible forever. Then decide each card's orientation with one unbiased bit.

5. Price it — free, no job, no charge

/estimate takes the exact body you are about to run and returns hold_credits, min_credits, model, model_alias and markup_bps. The hold prices the full output cap; the actual charge is usually far lower.

6. Run it and poll

Metered. Poll /jobs/{job_id} until status is succeeded or failed. If the balance sits between min_credits and hold_credits the run still executes with a reduced output cap and returns truncated: true — render what parsed rather than presenting a clipped reading as complete.

7. Or stream it

Same body, same billing, same idempotency rules. Concatenating every delta.text yields exactly what /run would have returned, so you can show progress without a second contract.

Rate limits and good manners

/estimate and /me are free; there is no reason to run without pricing first. Data endpoints share 120 requests a minute, and vector similarity is tighter at 30 a minute — debounce anything user-driven. On a 429, back off; never tight-loop.

Send an Idempotency-Key on every /run and /run-stream. Derive it from the input — the app itself uses tarot-reading:<spread>:<hash of question + context + drawn card ids>:a<attempt>. A reformat retry must reuse the key derived from the same input with a bumped attempt suffix, so a malformed first reply cannot bill twice.

One more time, because it is the whole design

You draw. The model reads. Then you check the reading against the draw. If you skip the third step you have given up the only guarantee this app offers — that the cards were not chosen to suit the answer.