Credits
Every sheet costs 4 credits. A job reserves them for all its sheets when
it is submitted, spends each sheet’s once it has been read, and refunds a sheet that could not be read - or the
whole job, when its file could not be split. A job’s own credits say where it stands: perSheet, reserved,
spent, refunded, and held (reserved and not yet spent or refunded).
GET /v1/account answers the balance first as sheetsRemaining: how many more drawing sheets the workspace can read
now, the number a person reads (GitHub #57: sheets for people, credits for the ledger; added 2026-10-14). The credit
fields stay as they were. GET /v1/account and GET /v1/workspace both answer the workspace’s balance as credits:
| Property | What it is |
|---|---|
available |
What a new submission can reserve now: unexpired credits only. |
held |
Reserved for jobs still being read. |
spent |
Spent on sheets, all time. |
perSheet |
What one sheet costs. |
sheetsAvailable |
How many sheets available pays for: the same number as sheetsRemaining. |
nextExpiry |
{ "credits", "expiresAt" }: the soonest any available credits stop counting, and how many stop then. null when none will. |
Credits can expire: a free trial’s at the trial’s end, a plan’s or a pack’s at the end of its period. They are
spent soonest-expiring first, so the credits about to expire are the ones used. A submission needing more than
available is refused whole with 402 not_enough_credits, and its problem says how many it needs, how many are
available and where to get more (upgradeUrl: the dashboard’s Billing and credits page, which shows the workspace’s credits
and sells plans and credit packs). Both endpoints state the plan as plan:
| Property | What it is |
|---|---|
name |
starter, pro, business or scale while the workspace is on that plan (added 2026-10-14); else trial, trialEnded or none. More values may be added: treat one you do not know as a plan. |
trialEndsAt |
When the free trial ends or ended; null when there was none. |
renewsAt |
When the plan renews and its next month’s credits arrive; null when it is set to end, or there is no plan. |
endsAt |
When a cancelled plan ends, at the end of the month paid for; null otherwise. |
paymentFailed |
true while the plan’s last payment failed and Stripe is retrying it: reading continues meanwhile, and the workspace’s owner updates the card on the Billing and credits page. null when there is no plan. |
GET /v1/account also states the limits this key works within as limits:
| Property | What it is |
|---|---|
requestsPerMinute |
This key’s rate limit (Rate limits). |
maximumFileSizeMegabytes |
The largest file a submission takes, in MB. |
maximumSheetsPerFile |
The most sheets one file may hold. |
maximumPageMegapixels |
The most pixels one page may hold, in millions. |
retentionDays |
How many days after a job finishes its drawing is kept: the uploaded file, the page images and every value’s box (How long the drawing is kept). |
curl -sS https://api.speccapture.com/v1/account \ -H "Authorization: Bearer $SPECCAPTURE_API_KEY"Paste into a terminal with SPECCAPTURE_API_KEY set. Needs curl; scripts also use bash and jq.
using System.Text.Json;
using HttpClient http = new() { BaseAddress = new Uri("https://api.speccapture.com") };http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("SPECCAPTURE_API_KEY"));
HttpResponseMessage response = await Call(HttpMethod.Get, "/v1/account");Console.WriteLine(await response.Content.ReadAsStringAsync());
// Sends a request; stops with the problem's code and detail if the API refuses it.async Task<HttpResponseMessage> Call(HttpMethod method, string path, HttpContent? body = null, string? idempotencyKey = null) { using HttpRequestMessage request = new(method, path) { Content = body }; if (idempotencyKey is not null) request.Headers.Add("Idempotency-Key", idempotencyKey); HttpResponseMessage response = await http.SendAsync(request); if (!response.IsSuccessStatusCode) { JsonElement problem = await Json(response); string? detail = (problem.TryGetProperty("detail", out JsonElement text) ? text : problem.GetProperty("title")).GetString(); Console.Error.WriteLine($"{(int)response.StatusCode} {problem.GetProperty("code")}: {detail}"); Environment.Exit(1); } return response;}
// Reads an answer as JSON.static async Task<JsonElement> Json(HttpResponseMessage response) => JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;Save as account.cs, set SPECCAPTURE_API_KEY, and run: dotnet run account.cs. Needs .NET 10 or later.
import osimport sys
import requests
API = "https://api.speccapture.com"session = requests.Session()session.headers["Authorization"] = f"Bearer {os.environ['SPECCAPTURE_API_KEY']}"
def call(method, path, **kwargs): """Send a request; stop with the problem's code and detail if the API refuses it.""" response = session.request(method, API + path, **kwargs) if not response.ok: problem = response.json() sys.exit(f"{response.status_code} {problem['code']}: {problem.get('detail', problem['title'])}") return response
print(call("GET", "/v1/account").text)Save as account.py, set SPECCAPTURE_API_KEY, and run: python account.py. Needs Python 3.9+ and requests (pip install requests).
const API = "https://api.speccapture.com";const headers = { Authorization: `Bearer ${process.env.SPECCAPTURE_API_KEY}` };
console.log(await call("GET", "/v1/account").then((response) => response.text()));
// Sends a request; stops with the problem's code and detail if the API refuses it.async function call(method, path, init = {}) { const response = await fetch(API + path, { ...init, method, headers: { ...headers, ...init.headers } }); if (!response.ok) { const problem = await response.json(); console.error(`${response.status} ${problem.code}: ${problem.detail ?? problem.title}`); process.exit(1); } return response;}Save as account.mjs, set SPECCAPTURE_API_KEY, and run: node account.mjs. Needs Node.js 20 or later.
Where the credits came from and went
Section titled “Where the credits came from and went”GET /v1/credits/history lists every change to the workspace’s credits, newest first and a page at a time
(Lists and paging): any key may read it, as any key reads the balance. Each entry has a type:
type |
What happened | availableChange |
heldChange |
|---|---|---|---|
grant |
Credits given: grantKind says where from - trial, plan (a plan’s month), pack, or addedBySpecCapture - with expiresAt and what of it is still unspent and counting (remaining) |
+ | 0 |
reservation |
A job’s sheets reserved when it was accepted | - | + |
spend |
One sheet read: its share of the hold spent | 0 | - |
refund |
A sheet that could not be read, or a whole job whose file could not be split | + | - |
expiry |
A grant’s expiresAt passed with credits unspent: they stopped counting. It carries its grant’s entryId and grantKind |
- | 0 |
takenBack |
The payment that bought a plan’s month or a pack was refunded or lost to a dispute, and its credits were taken back - never more than were still unspent (GitHub #160; docs/billing.md, “Refunds and disputes”). grantKind says which kind of grant |
- | 0 |
Every entry has its entryId, the credits it moved (always more than 0), occurredAt, and its effect on the balance,
so the whole history adds up: the availableChanges sum to credits.available, the heldChanges to credits.held,
and the spends’ credits to credits.spent. A reservation, spend or refund names its job (jobId, fileName - null
once the job was deleted - and sheetNumber for one sheet). type narrows the history to one kind of entry, and
createdAfter / createdBefore to when they happened.