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.

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.