API documentation

Genesis blocks, as a service.

A small REST API in front of a real mining ASIC. You POST an inscription; the foundry strikes it into a proof-of-work genesis block at Bitcoin's original difficulty (or deeper), publishes it to the public vault with its own page, and tells you by webhook. Prepaid credits, no subscription, JSON everywhere.

Open the developer console Quickstart OpenAPI 3.1 spec

Overview#

Base URL: https://strikeablock.com/api/v1. Every request and response is JSON (Content-Type: application/json); every response carries X-API-Version: 2026-09-10. There is no sandbox: every strike is real work on real hardware, so buy a small pack and use the Genesis tier to integrate.

What a strike is. A block header whose double-SHA-256 hash starts with at least eight hex zeros (Bitcoin's difficulty on 3 January 2009), whose coinbase transaction carries your inscription, mined on a GekkoScience Compac F (Bitmain BM1397) ASIC. The block is a standalone artifact: it is not part of Bitcoin or any public chain, has no monetary value, and is published anonymously in the vault with a page at strikeablock.com/b/<block_id> that re-verifies the proof of work in the visitor's browser.

Shape of an integration.

you strikeablock.com the foundry (ASIC) ─── ──────────────── ────────────────── POST /v1/strikes ──────────────▶ charge credits, queued ──────▶ claims it: mining sweeps 2^32 nonces / 21 ms GET /v1/strikes/{id} ◀─────────── status, queue_ahead …finds a hash ≤ target publishes block, page, OG image POST https://you/hook ◀─────────── signed strike.done event ◀────── done (or rejected/failed → refund)

One physical machine strikes everything, first in, first out. Your strike never gets lost: if the foundry is offline it waits in the queue and is struck when the chip is back.

Quickstart#

  1. Get a key. Sign in at the developer console, create an API key, copy it (it is shown once).
  2. Buy credits on the same page (a $10 pack is enough for a Genesis strike). 1 credit = $1; a Genesis strike costs 7.
  3. Create a strike:
curl https://strikeablock.com/api/v1/strikes \
  -H "Authorization: Bearer sab_live_…" \
  -H "Content-Type: application/json" \
  -d '{"text": "For Mom, 1958-2024", "zeros": 8,
       "webhook_url": "https://example.com/strikeablock",
       "metadata": {"order": "A-1042"}}'

# 202 Accepted
{
  "id": "k3Jx9…", "object": "strike", "status": "queued",
  "text": "For Mom, 1958-2024", "zeros": 8, "tier": "genesis",
  "credits_charged": 7, "credits_remaining": 18, "queue_ahead": 0,
  "webhook_url": "https://example.com/strikeablock", "metadata": {"order": "A-1042"},
  "block": null, "url": null, "created_at": "2026-09-10T18:04:11.201Z", …
}
  1. Wait for it. Either poll GET /v1/strikes/{id} every few seconds until status is terminal, or handle the strike.done webhook. When it lands:
{
  "id": "k3Jx9…", "status": "done", "credits_charged": 7, "refunded": false,
  "queued_ms": 1840, "mine_ms": 2210,
  "block_id": "lHKO2Epan2YzwndTyFOd",
  "url": "https://strikeablock.com/b/lHKO2Epan2YzwndTyFOd",
  "block": {
    "hash": "000000002bfdd443b79ac22e8abc216d1c055598fb581c42113d8e6c0f69afec",
    "zeros": 8, "zeros_deep": 8, "nonce": 1930422341, "nonce_hex": "0x730fe845",
    "nbits": "0x1d00ffff", "timestamp": 1757527451,
    "header_hex": "01000000…", "coinbase_tx_hex": "01000000…", "merkle_root": "…",
    "target": "00000000ffff0000…", "device": "compacf-GS10061433", "mine_ms": 2210, …
  }
}

That is the whole loop. The rest of this page is detail.

Authentication#

Send your key as a bearer token on every request:

Authorization: Bearer sab_live_Ab3dEf…

Requests without a valid key get 401 with error.type = "authentication_error". Two endpoints need no key at all: GET /v1/pricing and the public block endpoints.

Credits & pricing#

Strikes are prepaid in credits. One credit is one US dollar. The price of a strike is its depth, the number of leading hex zeros you ask for. Every extra zero is sixteen times the expected work (the depth ladder has the math).

zerostiercreditswork vs. Bitcoin's block 0typical time once mining
00000000genesis71× (the original bar)seconds
0000000000gold10256×under a minute
00000000000platinum294,096×several minutes
000000000000prismatic9965,536×one to three hours

Nine zeros is not for sale: one in sixteen Genesis strikes lands a ninth zero by luck, and the block keeps it (zeros_deep reports what actually landed). GET /v1/pricing returns this table as JSON so you never hard-code it.

GET /v1/pricing public
GET /v1/credits key

Returns {"object": "balance", "credits": 18}.

Buying credits#

The simplest way is the console. From code, create a Stripe Checkout session and send the buyer to its URL:

POST /v1/credits/checkout key
fieldtypenotes
creditsintegerrequired. 10 to 1000. Charged at $1.00 each.
success_urlstringhttps URL to return to after payment. Include the literal {CHECKOUT_SESSION_ID} and Stripe substitutes the session id. Default: the console.
cancel_urlstringhttps URL for an abandoned checkout. Default: the console.
{
  "object": "checkout_session", "id": "cs_live_…",
  "url": "https://checkout.stripe.com/c/pay/cs_live_…",
  "credits": 25, "amount_cents": 2500, "currency": "usd",
  "expires_at": "2026-09-10T19:04:11.000Z", "status": "open"
}

Credits are added the moment Stripe reports the session paid, three ways, each safe to overlap: a Stripe webhook into this API, the buyer returning to success_url, and a periodic reconciliation by the foundry. If your own success_url receives the buyer, call claim to credit immediately rather than waiting for reconciliation:

POST /v1/credits/claim key
{"session_id": "cs_live_…"}

# →
{"object": "credit_claim", "session_id": "cs_live_…", "applied": true,
 "reason": null, "credits": 25, "balance": 43, "payment_status": "paid"}

applied is false with reason "already_applied" (harmless) or "unpaid" (try again in a moment). A session bought by a different account is refused with 403.

GET /v1/credits/ledger key

Every balance change, newest first: kind is purchase, strike, refund or grant, with delta, balance_after, and strike_id where relevant. Paginated with limit (≤ 200) and starting_after=<entry id>.

Refunds#

Credits are deducted when a strike is created and given back automatically when it does not land: status rejected (content, junk, or a duplicate of a block that landed first) or failed (the chip exhausted its search budget without a solution; rare, and never charged). The strike then shows "refunded": true and the ledger gets a refund row. Credits themselves are not refundable to cash except as required by law; see the terms.

Strikes#

Create a strike#

POST /v1/strikes key
fieldtypenotes
textstringrequired. The inscription, 3 to 90 characters after whitespace is collapsed. Any Unicode; no control characters. Written into the coinbase transaction exactly as sent and shown publicly in the vault. Must not already exist in the vault (409 duplicate_inscription) and must pass the content policy (422).
zerosinteger8 (default), 10, 11 or 12. Sets the price and the target; see pricing.
webhook_urlstringhttps URL to notify for this strike. Overrides the account webhook (if any) for this strike only. Signed with your account's secret.
metadataobjectUp to 10 keys ([A-Za-z0-9_.-], ≤ 40 chars) with string, number or boolean values (≤ 200 chars). Echoed back on the strike and in webhooks; never shown publicly.
emailstringOptional. Emails the block certificate (hash, nonce, full header, coinbase transaction, JSON attachment) to this address when the strike lands. Only that one message; never marketing.
headernotes
Idempotency-KeyOptional, ≤ 100 printable ASCII characters. See idempotency.

On success the response is 202 Accepted with a Location header pointing at the strike, the strike object, plus two create-time extras: credits_remaining and queue_ahead (strikes in front of yours across all customers). Validation and moderation happen before any credits move: a 4xx never charges you.

Spelling is forever. A struck plate cannot be edited or re-struck, and the vault is public. Validate on your side, show the user exactly what will be inscribed, and use the Idempotency-Key so a retried request cannot strike twice.

The strike object#

fieldtypemeaning
idstringStrike id. Use it to poll.
objectstringAlways "strike".
statusstringqueued · mining · done · rejected · failed. The last three are terminal.
textstringThe inscription as it will be (or was) struck.
zerosintegerDepth requested (8, 10, 11, 12).
tierstringgenesis · gold · platinum · prismatic.
credits_chargedintegerCredits deducted at creation.
refundedbooleanTrue once a rejected/failed strike's credits are back.
reasonstring|nullOn rejected: content, junk, duplicate or invalid. Otherwise null.
created_atISO 8601When you created it.
started_atISO 8601|nullWhen the chip picked it up (status left queued).
completed_atISO 8601|nullWhen it reached a terminal status.
queued_msinteger|nullTime spent waiting for the chip.
mine_msinteger|nullWall-clock time of the strike itself.
attemptsintegerMining passes so far (a pass that finds no solution is retried with fresh timestamps).
queue_aheadintegerOnly while queued: strikes ahead of this one.
webhook_urlstring|nullWhere this strike's events go.
metadataobjectYour metadata, verbatim.
block_idstring|nullSet on done.
blockobject|nullThe full block object on done.
urlstring|nullThe block's public page on done.

Lifecycle & timing#

queued ──▶ mining ──▶ done block published, webhook strike.done │ ├────▶ rejected content · junk · duplicate → refunded, webhook strike.rejected │ └────▶ failed search budget exhausted → refunded, webhook strike.failed

queued. Waiting for the single ASIC, first in first out across all customers. Usually seconds; longer if a deep strike is ahead of you (queue_ahead tells you how many). Moderation runs again here as a backstop.

mining. The chip sweeps all 4.3 billion nonces of a header in about 21 milliseconds. A header only contains a solution part of the time, so the foundry rolls the timestamp and tries fresh headers until one lands. Expected durations once mining starts:

zerostypicalsearch budget before failed
82–10 secondsabout a minute across retries (P(fail) ≈ 2%, then requeued up to three times)
10under a minutea few minutes
11several minutesup to ~45 minutes per attempt
12one to three hoursup to ~5 hours per attempt; may be requeued once more

Mining is a lottery; a strike can land early or late. Design for done arriving any time from seconds to hours, and never block a user-facing request on it.

done. The block is published to the vault, its page and social image exist, the certificate email (if requested) is queued, and the webhook fires. rejected and failed refund the credits before the webhook fires, so refunded is already true in the event.

Retrieve & list#

GET /v1/strikes/{id} key

The strike, with block joined once done and queue_ahead while queued. Strikes belong to the account that created them; any other account gets 404.

GET /v1/strikes key

Your strikes, newest first. Query parameters: limit (default 20, max 100), status (filter to one status), starting_after=<strike id> for the next page. The list omits block; fetch a strike for the full block.

{"object": "list", "data": [ …strikes… ], "has_more": true, "next_cursor": "k3Jx9…"}

Idempotency#

Send an Idempotency-Key header (any unique string per intended strike, an order id works well) with POST /v1/strikes. A retry with the same key and the same account returns the original strike with 200 and the header Idempotent-Replayed: true, and charges nothing. Keys are scoped per account and kept indefinitely. Because the vault also refuses duplicate inscriptions, a retried strike without the header would be rejected (and refunded) rather than struck twice, but the header spares you the round trip.

Polling#

Polling works well for genesis and gold strikes and as a fallback for the rest. Guidance:

# Python
import time, requests
H = {"Authorization": "Bearer sab_live_…"}
s = requests.post("https://strikeablock.com/api/v1/strikes", headers=H,
                  json={"text": "For Mom, 1958-2024"}).json()
while s["status"] in ("queued", "mining"):
    time.sleep(4)
    s = requests.get(f"https://strikeablock.com/api/v1/strikes/{s['id']}", headers=H).json()
print(s["status"], s.get("url"))

Webhooks#

A webhook is an HTTPS POST from the foundry to you the moment a strike reaches a terminal status. Every delivery is signed with your account's secret, retried for about a day, and inspectable afterwards.

Configuring#

Two levels, and you can use both: an account webhook receives every strike's events unless the strike names its own webhook_url, which then receives that strike's events instead. Both are signed with the one account secret.

PUT /v1/webhook key
{"url": "https://example.com/strikeablock", "rotate_secret": false}

# →
{"object": "webhook", "url": "https://example.com/strikeablock",
 "secret": "whsec_…", "events": ["strike.done", "strike.rejected", "strike.failed"],
 "signature_header": "X-SAB-Signature", "updated_at": "…"}

The secret is created on first use and returned by GET /v1/webhook whenever you need it. Pass "rotate_secret": true to replace it; deliveries queued after that moment use the new one.

GET /v1/webhook key
DELETE /v1/webhook key

Removes the account URL (the secret stays, so per-strike URLs keep working).

POST /v1/webhook/test key

Queues a strike.test event carrying a sample done strike (Satoshi's block 0) to your account URL, or to a {"url": …} you pass. It goes through the same signing and retry machinery as a real event, so use it to prove your verifier end to end. Returns 202 with a delivery_id.

Events & payload#

typewhen
strike.doneThe block landed. data.strike.block is the full block.
strike.rejectedModeration or duplicate at mining time; credits already refunded. data.strike.reason says why.
strike.failedThe search budget ran out; credits already refunded.
strike.testOnly from POST /v1/webhook/test; livemode is false.
POST /strikeablock HTTP/1.1
Content-Type: application/json
User-Agent: StrikeABlock-Webhooks/1.0 (+https://strikeablock.com/docs.html#webhooks)
X-SAB-Event: strike.done
X-SAB-Strike-Id: k3Jx9…
X-SAB-Delivery-Id: 7Qm…
X-SAB-Attempt: 1
X-SAB-Signature: t=1757527456,v1=5f8c…e21a

{
  "id": "evt_…", "object": "event", "type": "strike.done",
  "api_version": "2026-09-10", "created": 1757527456, "livemode": true,
  "data": { "strike": { …the strike object, with block… } }
}

Respond with any 2xx within 10 seconds. Do your real work after acknowledging (queue it); a slow handler is retried as a failure and you will see the same event twice.

Verifying signatures#

X-SAB-Signature is t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 with your secret over the string <t>.<raw body>. Verify against the raw request bytes, not a re-serialized object, and reject timestamps older than a few minutes to blunt replays. This is the scheme Stripe uses, so an existing verifier ports in one line.

import hmac, hashlib, time

def verify(secret: str, header: str, body: bytes, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts["v1"], expected)

# Flask
@app.post("/strikeablock")
def hook():
    if not verify(SECRET, request.headers["X-SAB-Signature"], request.get_data()):
        return "", 400
    event = request.get_json()
    if event["type"] == "strike.done":
        mark_struck(event["data"]["strike"]["metadata"]["order"],
                    event["data"]["strike"]["url"])
    return "", 200

Handle events idempotently: key on data.strike.id (or the event id) and ignore a repeat. Retries and the test endpoint can both deliver the same event more than once.

Retries & deliveries#

A delivery that does not get a 2xx within 10 seconds (timeouts, connection errors, redirects and 4xx/5xx included) is retried on this schedule after the previous attempt: 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h. Eight attempts in all, about a day; after that the delivery is failed and the strike is still there to poll. Deliveries are sent from the foundry itself (not from a cloud IP range), so allow-listing by IP is not supported; verify signatures instead.

GET /v1/strikes/{id}/deliveries key
GET /v1/webhook/deliveries key
{"object": "list", "data": [{
  "id": "7Qm…", "object": "webhook_delivery", "strike_id": "k3Jx9…",
  "event": "strike.done", "url": "https://example.com/strikeablock",
  "state": "delivered",              // pending · delivered · failed
  "attempts": 2, "next_attempt_at": null,
  "last_attempt_at": "…", "delivered_at": "…",
  "last_status": 200, "last_error": null, "created_at": "…"
}], "has_more": false, "next_cursor": null}

Blocks & verification#

GET /v1/blocks/{block_id} public
GET /v1/blocks public

Any public block, by id, or the vault newest first (limit ≤ 100, starting_after). Cacheable for a minute. The block object:

fieldmeaning
idBlock id; the page is https://strikeablock.com/b/{id} (also in url).
textThe inscription in the coinbase.
hashDouble-SHA-256 of the 80-byte header, displayed big-endian as Bitcoin does. 64 hex chars.
zeros / zeros_deepDepth requested / leading zeros actually present (luck can add one).
nonce / nonce_hexThe winning nonce.
nbits / targetCompact difficulty and the expanded 256-bit target (hex). A valid block has hash ≤ target.
timestampHeader nTime (unix). Deep strikes roll this forward, so it can run ahead of the clock.
header_hexThe full 80-byte header: version · prev-hash (zeros; it is a genesis block) · merkle root · time · nBits · nonce.
coinbase_tx_hexThe coinbase transaction carrying the inscription. Its txid is the merkle root.
merkle_rootBig-endian merkle root taken from the header.
deviceThe chip that struck it.
mine_ms / mined_atHow long the strike took and when it landed.

Verify it yourself. Trust nothing you have not hashed: the proof is two SHA-256 calls away.

# Python
import hashlib
b = requests.get("https://strikeablock.com/api/v1/blocks/lHKO2Epan2YzwndTyFOd").json()
header = bytes.fromhex(b["header_hex"])
digest = hashlib.sha256(hashlib.sha256(header).digest()).digest()[::-1].hex()
assert digest == b["hash"]
assert int(digest, 16) <= int(b["target"], 16)
assert b["text"].encode() in bytes.fromhex(b["coinbase_tx_hex"])

The same check runs in every visitor's browser on the block page, which is also what the Block Coin's NFC chip opens.

In-browser agents (WebMCP)#

The REST API above is for code. For an AI agent that is already in the visitor's browser (Gemini in Chrome, an agent extension), the site's pages also register typed tools on document.modelContext, the WebMCP draft standard. Same idea as MCP, but the page is the server: the agent discovers tools with a name, a description and a JSON Schema, calls them, and gets JSON back, instead of reading the page and clicking buttons. Chrome ships WebMCP in an origin trial (Chrome 149–156) and behind chrome://flags/#enable-webmcp-testing; in other browsers the pages are unchanged and the tools simply do not exist. Every tool is a thin wrapper over what the page already does, so what the agent sees is what the visitor sees.

pagetoolinputreturns
every pageget_site_summary—What the site is and is not, the pages, this API, the contact address.
homeget_pricing—The four tiers with zeros, price, typical time and the Stripe checkout URL (returned, never opened).
homemine_toy_blocktext, zeros 3–5Runs the free browser miner on screen; hash, nonce, timing. Cancels on abort.
homelist_recent_blockslimit ≤ 24The newest public blocks from the live vault.
home, signed inqueue_strikeinscriptionA declarative form tool: the agent fills the inscription, a person still clicks Strike it. Only present for signed-in accounts.
vaultlist_blockslimit ≤ 50, cursorPages through every public block, newest first, with a nextCursor block id.
block pageget_block—The block on the page: inscription, hash, nonce, header, depth, date, coin availability.
block pageverify_block—Re-hashes the header in the browser and reports whether it matches.
block pageget_coin_checkout_link—The Block Coin checkout URL for this block, $39, US only (returned, never opened).

Read-only tools carry readOnlyHint; anything that returns an inscription carries untrustedContentHint, because inscriptions are written by other visitors and an agent should quote them, not obey them. The coin link carries consequentialHint so a well-behaved agent confirms before showing it. No tool pays, navigates, or writes to the vault. To try it yourself, enable the flag, open any page and run await document.modelContext.getTools() in DevTools.

Desktop agents (MCP server)#

For agents that live outside the browser (Claude Code, Codex, Cursor, Claude Desktop, anything that speaks the Model Context Protocol), the strikeablock-mcp npm package is a small stdio MCP server that wraps this API. It needs Node 20 or newer. The public tools work with no key; to strike, mint a key in the console and pass it as STRIKEABLOCK_API_KEY.

# Claude Code
claude mcp add strikeablock -e STRIKEABLOCK_API_KEY=sab_live_… -- npx -y strikeablock-mcp
# Codex (~/.codex/config.toml)
[mcp_servers.strikeablock]
command = "npx"
args = ["-y", "strikeablock-mcp"]
env = { STRIKEABLOCK_API_KEY = "sab_live_…" }
# Cursor, Windsurf, Claude Desktop (mcp.json / claude_desktop_config.json)
{"mcpServers": {"strikeablock": {
  "command": "npx", "args": ["-y", "strikeablock-mcp"],
  "env": {"STRIKEABLOCK_API_KEY": "sab_live_…"}}}}
toolkeywrapsreturns
get_pricingnoGET /v1/pricingThe four tiers with zeros, credits, typical time and limits.
list_blocksnoGET /v1/blocksThe public vault, newest first, with next_cursor.
get_blocknoGET /v1/blocks/{id}One block: inscription, hash, nonce, header, coinbase, url.
verify_blocknolocalDouble SHA-256 of the header on the agent's own machine: hash_matches, meets_target, text_in_coinbase, leading_zeros. By id, or from fields with no network at all.
get_creditsyesGET /v1/credits + ledgerBalance and the last few ledger rows, plus where to buy.
create_strikeyesPOST /v1/strikesCharges credits. Takes text, zeros, idempotency_key, email, webhook_url, metadata. Returns the queued strike with credits_remaining and queue_ahead.
get_strikeyesGET /v1/strikes/{id}Status, queue position, reason and refund, the block once done.
wait_for_strikeyespolls GET /v1/strikes/{id}Blocks until done, rejected or failed, or until max_wait_seconds (default 120, max 600), then returns the current state with timed_out.
list_strikesyesGET /v1/strikesYour strikes, newest first, optional status filter.

Every read-only tool carries readOnlyHint. create_strike is the one tool that spends money, so its description repeats the content policy, the "spelling is forever" warning, and an instruction to show the user the exact text and price and get their confirmation before calling; it also asks for an idempotency_key so a retried call cannot strike twice. Errors come back as this API's error object plus a hint (a 402 says where to buy credits). The server never buys credits, never opens a browser, and never writes anything except the strike you confirmed. Source and tests live in the open-source repository under mcp/.

From your terminal (CLI)#

The same code ships as a command, for people and scripts rather than agents. Node 20 or newer; the read-only commands need no key and no account.

# check a block on your own machine
npx strikeablock verify UpUWVohDQQOXHFZYUQIG
  “Agent-ready. Struck by Claude Code over MCP, 17/Sep/2026”

  hash        00000000002dcba74ab901cdf307020459f01c045c3f9784ceb4c3346df5de4e
  recomputed  matches
  target      hash is at or below it
  coinbase    inscription found inside
  zeros       10 leading

  ✓ verified  double SHA-256 of the 80-byte header, on this machine

It fetches the block's public 80-byte header and does the arithmetic on your machine: the header is hashed twice and compared with the recorded hash, that hash is checked against the target as a 256-bit integer, and the inscription is checked byte-for-byte inside the coinbase transaction. It exits 0 when every check passes and 1 when any fails, so it can gate a script. Nothing about the result depends on trusting this site.

commandkeywhat it does
verify <block-id>noThe check above. Exit 1 on failure.
block <block-id>noOne block: inscription, hash, nonce, depth, page.
blocks --limit 10noThe public vault, newest first.
pricingnoThe four tiers and what each costs.
creditsyesYour balance.
strikes [--status done]yesYour strikes, newest first.
status <strike-id>yesOne strike, with its block once it lands.
strike "<text>" [--zeros 8] [--wait]yesSpends credits. Prints the inscription and the price and waits for a yes.

Add --json to any command for machine-readable output, and set STRIKEABLOCK_API_KEY (or pass --key) for the last four. strike cannot be undone, so it confirms first; --yes skips the prompt and on a non-interactive stdin without it the command refuses rather than guessing.

Two package names, on purpose. npx strikeablock … is this CLI; npx -y strikeablock-mcp is the MCP server that agents launch. npm resolves package names rather than command names, so each needs its own. Both are MIT and built from the same source.

Account & keys#

GET /v1/account key
{"object": "account", "id": "…", "email": "you@example.com",
 "credits": 18, "strikes_total": 12, "pending_strikes": 1,
 "webhook": {"url": "https://example.com/strikeablock", "configured": true},
 "created_at": "…", "auth": "key", "pricing": { …the pricing object… }}

Key management is meant for the console, which calls these endpoints with a Google sign-in token rather than an API key. An API key cannot create or revoke keys (403 portal_only).

POST /v1/keys sign-in token
GET /v1/keys key or sign-in token
DELETE /v1/keys/{id} sign-in token

A created key returns key exactly once; listings show only prefix (sab_live_Ab3dEf…), name, created_at, last_used_at (updated at most once a minute) and revoked_at.

Errors#

Errors are JSON with a stable machine-readable code and a human message:

{"error": {"type": "insufficient_credits", "code": "insufficient_credits",
           "message": "This strike costs 10 credits; your balance is 3. …",
           "required": 10, "balance": 3}}
statustypecodes
400invalid_request_errortext_required text_too_short text_too_long text_control_chars invalid_zeros invalid_webhook_url invalid_email invalid_metadata invalid_json body_too_large invalid_idempotency_key invalid_credits invalid_success_url invalid_cancel_url invalid_session_id not_credits no_webhook. The offending field is in param.
401authentication_errormissing_key invalid_key revoked_key invalid_token
402insufficient_creditsinsufficient_credits with required and balance.
403authentication_errorportal_only wrong_account
404not_foundresource_missing unknown_route unknown_version
409invalid_request_errorduplicate_inscription (with existing_block_id and existing_block_url) queue_full too_many_keys
422moderation_errorrejected_content rejected_junk; reason repeats the short form.
429rate_limit_errorrate_limited, with a Retry-After header in seconds.
502 / 503api_errorstripe_error (Stripe declined or was unreachable) · purchases_unavailable (checkout not configured).
500api_errorinternal. Retry with backoff; email us if it persists.

Rate limits & quotas#

Every strike you queue is a promise of real hardware time, so the API is generous with reads and careful with writes. Cache GET /v1/pricing; it changes rarely and any change lands here in the changelog first.

End-to-end examples#

Python: strike, wait by webhook, verify

import hashlib, hmac, time, requests
from flask import Flask, request

API = "https://strikeablock.com/api/v1"
H = {"Authorization": "Bearer sab_live_…"}
SECRET = requests.get(f"{API}/webhook", headers=H).json()["secret"]   # after PUT /v1/webhook once

def strike(order_id: str, text: str, zeros: int = 8) -> dict:
    r = requests.post(f"{API}/strikes", headers={**H, "Idempotency-Key": order_id},
                      json={"text": text, "zeros": zeros, "metadata": {"order": order_id}})
    if r.status_code == 402:
        raise RuntimeError("top up: " + r.json()["error"]["message"])
    r.raise_for_status()
    return r.json()

app = Flask(__name__)

@app.post("/strikeablock")
def hook():
    sig = dict(p.split("=", 1) for p in request.headers["X-SAB-Signature"].split(","))
    expected = hmac.new(SECRET.encode(), f"{sig['t']}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig["v1"], expected) or abs(time.time() - int(sig["t"])) > 300:
        return "", 400
    ev = request.get_json(); s = ev["data"]["strike"]
    if ev["type"] == "strike.done":
        b = s["block"]
        h = hashlib.sha256(hashlib.sha256(bytes.fromhex(b["header_hex"])).digest()).digest()[::-1].hex()
        assert h == b["hash"] and int(h, 16) <= int(b["target"], 16)
        fulfil(s["metadata"]["order"], s["url"], b["hash"])
    elif ev["type"] in ("strike.rejected", "strike.failed"):
        notify_customer(s["metadata"]["order"], s["reason"] or "unlucky")   # credits already back
    return "", 200

Node: strike and poll

const API = "https://strikeablock.com/api/v1";
const H = { Authorization: "Bearer sab_live_…", "Content-Type": "application/json" };

async function strikeAndWait(text, zeros = 8) {
  let r = await fetch(`${API}/strikes`, { method: "POST", headers: H, body: JSON.stringify({ text, zeros }) });
  if (!r.ok) throw new Error((await r.json()).error.message);
  let s = await r.json();
  const every = { 8: 4000, 10: 10000, 11: 30000, 12: 60000 }[zeros];
  while (s.status === "queued" || s.status === "mining") {
    await new Promise(res => setTimeout(res, every + Math.random() * 1000));
    r = await fetch(`${API}/strikes/${s.id}`, { headers: H });
    if (r.status === 429) { await new Promise(res => setTimeout(res, 1000 * (+r.headers.get("retry-after") || 5))); continue; }
    s = await r.json();
  }
  return s;   // done (with s.block, s.url) · rejected · failed
}

Content policy#

The vault is public and permanent, so every inscription is gated twice: at POST time (a 422 that costs nothing) and again by the foundry before the chip spends a hash (a rejected strike, refunded). The gate is a wordlist, not a judge: slurs, strong profanity and sexual terms are refused (content); filler like test, keyboard mashes and repeated characters are refused (junk); an inscription already in the vault is refused (duplicate). Mild language, any script, dates, names and numbers are all fine. Do not inscribe other people's personal data, anything you do not have the right to publish, or anything illegal; blocks can be hidden from the vault after the fact for those reasons, and the terms govern. If your use case needs private (unlisted) blocks, tell us.

Versioning & changelog#

The API is versioned by date; the current version is 2026-09-10 and is echoed in X-API-Version and in every webhook's api_version. Additive changes (new fields, new event types, new endpoints) ship without a version bump; anything that would break a correct client gets a new dated version and notice by email to every account with an active key. The full machine-readable contract is the OpenAPI 3.1 document.

Questions, higher limits, private blocks, or a use case we should hear about: hello@strikeablock.com.