Starkscan

STRK20 prover relay

Submit STRK20 transaction proofs through Starkscan as asynchronous jobs, with per-key budgets and an explicit delivery-safety contract.

STRK20 prover relay

Starkscan is the authenticated front door for the Starknet Foundation's STRK20 transaction prover. You submit a proof request with your API key, Starkscan queues it, forwards it, and hands back the proof.

Access is operator-issued and mainnet-only. Prove scope cannot be created from the API key page or redeemed through an access invite; an operator issues it only after a builder is approved.

Two different "not available" responses are worth distinguishing before you debug a client:

  • 404 — the relay is not enabled in that environment. The routes are not registered at all, so every caller gets 404 regardless of key or scope. This is the current state everywhere; the surface is dormant until the relay is turned on.
  • 403 — the relay is enabled but your key lacks prove scope.

So a 404 is not a wrong URL and a 403 is not a missing route.

Why it is asynchronous

A proof occupies a dedicated prover slot for an unbounded workload-dependent duration. Rather than hold an HTTP response open, proving is a job: you submit, you get a jobId, you poll. Starkscan does not publish a latency or throughput promise until representative workload evidence is certified.

Before you submit

The prover simulates your transaction at the block you pin and refuses to prove one that reverts. Three preconditions are easy to miss, because each surfaces as an error about something else.

Pin an explicit block accepted by the prover

Use an explicit finalized block rather than relying on a moving tag. The allowed distance from head is prover policy and may change. Do not hard-code an offset from one observed response: if the prover reports that the block is too recent, repin to an older explicit block.

State you depend on must exist at the pinned block

Because simulation happens at the pinned block, anything the transaction needs — an ERC20 approval, pool registration, channel setup — must already be on chain there, not merely at the head. Wait until the setup state exists at the explicit block you will prove; do not infer pinned-state readiness from elapsed time or from one error message.

Allowance must cover the amount plus the pool fee

The pool charges get_fee_amount() per apply_actions on top of whatever you deposit or transfer, and pulls both over the same ERC20 allowance. Approving only the transfer amount reverts with:

Insufficient ERC20 allowance

Read get_fee_amount() from the pool at the pinned block rather than assuming a fee. Estimate gas for the exact transaction before signing; this page does not publish an unverified fee or gas figure.

Submit a proof

curl -X POST \
  -H "X-Starkscan-Api-Key: $STARKSCAN_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H 'content-type: application/json' \
  -d '{
        "block_id": {"block_number": 12446898},
        "transaction": { "...": "an Invoke transaction" }
      }' \
  "https://api.starkscan.co/v1/SN_MAIN/prove"

Idempotency-Key is required

Submission requires an Idempotency-Key header: 16–128 graphic ASCII characters — no spaces, no control characters, no ". Surrounding whitespace is rejected too. This is not boilerplate — it is what makes retrying safe. A UUID is a good default and fits the range.

A proof holds the only prover slot for its whole duration and costs a unit of your daily budget. If your 202 is lost to a timeout or a dropped connection you have no job id to poll, and a naive retry would start a second proof. With the key, the retry returns the original job instead.

SituationResponse
New key202 with a new jobId
Same key, same body200 with the original jobId. No second proof, no second budget debit
Same key, different body409 idempotency_key_reused
Missing or malformed key400 idempotency_key_required / invalid_idempotency_key

Notes:

  • Use one fresh key per logical submission — a UUID is ideal — and reuse that same key for every retry of it.
  • Keys are scoped to your workspace, so they cannot collide with another tenant's.
  • Body comparison ignores JSON object key ordering, so re-serializing your request on retry is safe. Array order is significant, since calldata order is.
  • Concurrent duplicates coalesce: fire the same key twice at once and exactly one job is created.
  • Starkscan stores only one-way digests of the key and of your request, never the key itself and never the payload.

block_id and transaction are passed to the prover unchanged. The transaction must be an Invoke transaction; the prover rejects other kinds.

HTTP/1.1 202 Accepted
X-Starkscan-Rpc-Class: rpc_prove
{
  "jobId": "prv_9f2c1ab34de56789012345ab",
  "status": "queued",
  "terminal": false,
  "attemptCount": 0,
  "queuePosition": 2,
  "pollAfterSeconds": 10,
  "createdAt": "2026-07-29T12:00:00+00:00"
}

Poll the job

curl \
  -H "X-Starkscan-Api-Key: $STARKSCAN_API_KEY" \
  "https://api.starkscan.co/v1/SN_MAIN/prove/$JOB_ID"

Poll until terminal is true. Honor pollAfterSeconds; do not spin.

statusterminalMeaning
queuedfalseAccepted, waiting for the prover
dispatchedfalseProof running
succeededtrueresult carries the proof
failedtrueYour request was rejected; error.code is the prover's own code
unavailabletrueRetry the same key safely; before opening a new job, see the caveat below
unknown_deliverytrueThe prover may have received the request. Do not resubmit automatically

A successful poll:

{
  "jobId": "prv_9f2c1ab34de56789012345ab",
  "status": "succeeded",
  "terminal": true,
  "attemptCount": 1,
  "createdAt": "2026-07-29T12:00:00+00:00",
  "completedAt": "2026-07-29T12:04:31+00:00",
  "result": {
    "proof": "...",
    "proof_facts": "...",
    "l2_to_l1_messages": [],
    "additional_data": {
      "signature": {
        "issued_at": 1785326580,
        "sig_r": "0x...",
        "sig_s": "0x..."
      }
    }
  }
}

result is the prover's response passed through verbatim: Starkscan adds nothing and removes nothing. additional_data is present only for screened deposits — see Screened deposits below, because dropping it makes the transaction revert on chain.

The result is delivered once

Read this before you build a client. Starkscan never writes proof payloads to disk. A completed proof is held in memory, delivered on the first successful poll, and then dropped. It also expires, and it does not survive a relay restart.

So: persist the entire result object the moment you receive it — not just proof. Every field is delivered together and dropped together, and for a deposit the transaction is unusable without additional_data. If you poll again, or poll too late, you get the job with no result and:

{
  "status": "succeeded",
  "terminal": true,
  "resultUnavailableReason": "delivered_or_expired"
}

The proof is not recoverable. You must resubmit, which costs another slot and another unit of your daily budget. This is the deliberate cost of Starkscan not retaining what you asked it to prove.

Screened deposits carry an expiring attestation

When your transaction deposits into the pool, the prover screens the depositor while it proves and attaches the resulting attestation to the response under additional_data.signature. Starkscan relays it verbatim and never inspects, persists it to disk, or logs it. The complete response is temporarily retained in relay memory until its first delivery, expiry, eviction, or a service restart.

Do not drop it. The pool's apply_actions takes a trailing attestation parameter that must be present if and only if the transaction contains a deposit:

TransactionAttestationOn chain
DepositpresentAccepted
DepositmissingReverts SCREENING_REQUIRED
Transfer or withdrawalpresentReverts UNEXPECTED_SCREENING
Transfer or withdrawalabsentAccepted

If you use the privacy SDK, populate Proof.additionalData from this field and the SDK builds the calldata for you; AdditionalData and ScreeningSignature are exported types. If you assemble calldata yourself, treat the pool contract as the specification — the encoding belongs to the pool, not to this API.

It expires in five minutes

issued_at is stamped when the prover screens, which happens while it proves. Time your job spent queued at Starkscan does not count against it.

The pool rejects an attestation older than 300 seconds (DEPOSITOR_VALIDATION_MAX_AGE in the pool contract, which is the source of truth if it changes), measured against the block timestamp when apply_actions executes. That deadline has to cover your poll interval, your broadcast, and inclusion — not just proving.

So check issued_at against the current time as soon as you receive the result. If the remaining margin will not cover broadcast and inclusion, prove again rather than broadcasting a transaction that reverts with SCREENING_EXPIRED. You cannot refresh an attestation yourself; only the screener issues them.

Note that the result cache outlives the attestation: a late poll can hand you a result that is still perfectly usable for a transfer and already dead for a deposit.

Errors

Three terminal outcomes tell you what to do next.

Your request — passed through with the prover's exact code, under error.source: "prover":

CodeMeaning
24Block not found
55Account validation failed
61Unsupported transaction version
1000Invalid transaction input
-32603Transaction reverted only when error.data begins Reverted transactions are not supported; hash: TransactionHash(. The remainder carries the transaction hash and the Cairo diagnostic, for example ('INVALID_SIGNATURE').

Absorbedstatus: "unavailable" with error.code: "prover_unavailable". This can be a relay failure or a complete prover error outside the table above. The prover's message is not surfaced for this outcome, and timing cannot distinguish the two. Retry the same idempotency key to recover the existing terminal job without another debit. If it persists, re-check the public preconditions above and contact support with the poll response's jobId and attemptCount before submitting a new logical job. A new key creates a new job and consumes another daily-budget unit; the same key does not.

A bare or otherwise unconfirmed -32603 is also absorbed. -32603 is the generic JSON-RPC internal-error code, so Starkscan only returns it as a caller transaction revert when the prover supplies the documented revert-data prefix.

For every passed-through error, the full upstream error object is held only in relay memory and delivered to the owning workspace on its first terminal poll. Starkscan never logs or stores its message or data. Persist the error object immediately: after a relay restart, expiry, or a later poll, the durable numeric error.code remains but error.data is unavailable.

A sanctions-screening rejection is passed through rather than absorbed: the deposit was refused, and resubmitting it unchanged will be refused again. For an absorbed failure, do not rotate your Starkscan API key; retry the same logical job key and contact support with jobId and attemptCount if it persists.

Delivery uncertainstatus: "unknown_delivery" with error.code: "prover_delivery_unknown". The relay cannot prove whether the prover received the request, so it intentionally does not resend it. Keep the jobId and attemptCount, then contact support before submitting a new proof.

Budgets

Prove requests are budgeted by concurrency and per-day volume, not per minute. One proof can hold the only slot for minutes, so a per-minute limit would be meaningless.

ResponseCodeMeaning
409idempotency_key_reusedThe key was already used for a different request
429prover_daily_budget_exhaustedOut of proofs for this UTC day. Retry-After points at UTC midnight
429prover_key_concurrencyYou already hold your maximum in-flight proofs
503prover_queue_fullShared queue saturated; self-clearing
503prover_unavailableProver is not currently available

Exact numbers are per-key and set when your access is provisioned. Honor Retry-After rather than hard-coding assumptions.

X-Starkscan-Rpc-Class is rpc_prove on these routes. See Rate limits.

A minimal client loop

set -euo pipefail

# One key per logical submission; reuse it on every retry of that submission.
IDEMPOTENCY_KEY="$(uuidgen)"

JOB_ID="$(curl -sS -X POST \
  -H "X-Starkscan-Api-Key: $STARKSCAN_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H 'content-type: application/json' \
  --data-binary @request.json \
  "https://api.starkscan.co/v1/SN_MAIN/prove" | jq -r '.jobId')"

while :; do
  body="$(curl -sS -H "X-Starkscan-Api-Key: $STARKSCAN_API_KEY" \
    "https://api.starkscan.co/v1/SN_MAIN/prove/$JOB_ID")"

  if [ "$(printf '%s' "$body" | jq -r '.terminal')" = "true" ]; then
    # Persist immediately: the result is delivered exactly once.
    printf '%s' "$body" | jq '.result' > proof.json
    printf '%s' "$body" | jq -r '.status'
    break
  fi

  sleep "$(printf '%s' "$body" | jq -r '.pollAfterSeconds // 10')"
done

Limits worth knowing

  • Mainnet only. There is no Sepolia prover.
  • Invoke transactions only.
  • Request bodies are capped at 1 MiB.
  • starknet_proveTransaction is the only proving operation exposed. It is not part of the public Starknet JSON-RPC specification.

On this page