Skip to main content

swissbitcoinpay-checkout

Take Lightning and on-chain payments for a Swiss Bitcoin Pay merchant from an online shop's server, in Python.

It implements the Swiss Bitcoin Pay checkout specification 0.1.0 (protocol 1), pinned in spec/ (spec/PIN.json): with the integration's secret key alone, your server makes the invoice of each order (a Lightning invoice, a deposit address of the merchant's wallet, or both), declares it in the merchant's registry on the merchant's Nostr relays, and checks its payment. Swiss Bitcoin Pay runs no server and no API: payments go straight to the merchant's wallet. The key can make invoices that pay the merchant; it can never spend funds.

Install

python -m pip install swissbitcoinpay-checkout

Python 3.11 to 3.15. Every dependency ships wheels for Linux (x86_64 and aarch64), Windows (x86_64) and macOS (Apple silicon): nothing is compiled at install time there. Elsewhere (an Intel Mac, Windows on ARM), cryptography publishes no wheel of the locked version and builds from its sources, which needs a Rust toolchain. The package is typed (py.typed).

The key is a secret

The merchant creates the integration in the Swiss Bitcoin Pay app and gives you its secret key, sbp_ck_…. Whoever holds it can make invoices and declare sales for the merchant, and read the deposit addresses the app gives the integration.

  • Keep it in your server's secrets: an environment variable, a vault, your platform's secret store. Never in code, a repository, a log, an error report, or anything sent to the browser.
  • The package never puts it, or any key derived from it, in a message, a log, an exception or a repr; a client cannot be pickled; an invalid key or option leaves no frame of the package holding the key's text (error trackers record the local variables of each frame).
  • If it leaks, ask the merchant to remove the integration in the app (the next invoice of any client then fails with RevokedError) and to create a new one.

One key, one shop. The merchant creates an integration for each shop (each system with its own orders' references), and two shops never share a key: an order's records in the merchant's registry are derived from the key and the order's reference, so two shops with one key would share their references, and an order of one would be found paid, or pending, with an invoice of the other. The servers of one shop share its key.

Quick start

A synchronous server (Django, Flask, any WSGI server, a task queue):

import os

from swissbitcoinpay_checkout import Client, Price

client = Client(os.environ["SBP_CHECKOUT_KEY"])

# Make the order's invoice, store it with the order, then show it.
invoice = client.create_invoice(ref="order-1042", fiat=Price("24.50", "CHF"), note="Order 1042")
order.invoice = invoice.to_json()  # the JSON of the specification: store it as it is
order.save()
qr_code(invoice.payment_uri)  # one QR code: Lightning, on-chain, or both

# Later (a page refresh, a scheduled job): check the payment of the invoice the order holds.
status = client.get_status(order.invoice)
if status.state == "paid":
    order.mark_paid(proof=status.preimage or status.txids)

An asynchronous server (FastAPI, any ASGI server):

import asyncio
import os

from swissbitcoinpay_checkout import AsyncClient

client = AsyncClient(os.environ["SBP_CHECKOUT_KEY"])


async def pay(order_ref: str) -> None:
    invoice = await client.create_invoice(ref=order_ref, sats=49_000)
    ...  # store invoice.to_json(), show invoice.payment_uri
    async with asyncio.timeout(15 * 60):
        status = await client.wait_for_payment(invoice, on_status=print)

Mark an order paid only on a paid status of the invoice that order holds: never because the customer's browser says so.

Synchronous or asynchronous

The protocol talks to every relay at once and goes on as soon as a majority answered, so the package is written once, on asyncio, and offered two ways, with the same options, methods and errors:

  • AsyncClient: await its methods in your event loop. Cancel a call as any asyncio task (asyncio.timeout, task.cancel()): it stops its requests and raises the cancellation.
  • Client: its methods block until they are done. Each call runs in an event loop of its own, in the calling thread, and takes a timeout in seconds (none by default; past it, the call stops its requests and raises TimeoutError). It never starts an event loop inside one that is already running: called from a thread that runs one, it raises RuntimeError rather than block that loop. There, use AsyncClient, or call Client from a worker thread (asyncio.to_thread).

A client keeps nothing between calls but its options: make one per process and share it between threads (or tasks). Each call reads the merchant's channel again, opens one connection per relay and closes them when it is done.

Orders

invoice = client.create_invoice(
    ref="order-1042",  # 1 to 64 of A-Z a-z 0-9 . _ : -; one order, one ref (below)
    sats=49_000,  # or fiat=Price("24.50", "CHF"): exactly one of the two
    duration_seconds=900,  # 60 to 3600: how long the invoice can be paid
    note="Order 1042",  # what the merchant's app shows (the ref when absent)
    checkout=False,  # True: a checkout link too (below)
    redirect_url=None,  # with checkout=True: where its page sends the customer once paid
)

sats is an int or a string of digits. Every invalid parameter raises InvalidOrderError naming it (param), before any request.

One order, one reference. A ref is never reused for another order by anything that holds the key (a test copy of the shop, a restored database included), and never taken from what the customer sends: take your order's own identifier.

One order, one invoice at a time. The invoices of an order are read in the merchant's registry, so the rule holds even when your server lost what it stored:

  • while an earlier invoice of the order may still be paid (until its end and 120 s), its payment is checked first, so that an invoice already paid is never handed out again (underpaid on-chain, its rest invoice is asked then, as get_status would); then create_invoice raises InvoicePendingError with that invoice (error.invoice, and with checkout=True a new link to it, error.checkout_url, whose page shows the rest): show it again. A check that fails is the error (the invoice may have been paid): call again;
  • past that time, while an on-chain payment of it is being confirmed, it raises InvoicePendingError with that invoice and no new link (checkout_url is None: the link you got goes on following the payment);
  • once an earlier invoice was paid, it raises OrderPaidError with that invoice and its status.

Several invoices of an order may still be paid when an earlier call failed once some of the relays took its invoice, and a later one, which did not read it, made another one: their payments are checked, and the order's records read again on every relay, at the same time. One of them paid: OrderPaidError, whatever the relays answer; a check failing: its error; fewer than a majority of the relays answering: NetworkError; else the one the merchant's relays hold on a majority, the one shown, is handed out.

Within one client, calls for the same ref run one after the other. Across processes, lock the order in your database around create_invoice.

Store the invoice as invoice.to_json(): the JSON of the specification, the same in every language, which get_status and wait_for_payment take back as it is (an Invoice, a DeclaredInvoice or that JSON). Invoice.from_json(...) reads it back.

find_invoices(ref) lists the invoices declared for an order, oldest first, to find an order's invoice again after a failure. It publishes nothing: to show an order's invoice again, call create_invoice, whose InvoicePendingError carries it once its records are on a majority of the relays again. merchant() gives the business's name, the Lightning address's bounds in sats and the number of deposit addresses the integration's lot holds (never the merchant's Lightning address).

Prices in a currency

from swissbitcoinpay_checkout import Price

invoice = client.create_invoice(ref="order-1043", fiat=Price("24.50", "CHF"))
invoice.sats  # 35502 at 69 012 CHF/BTC: rounded up, the merchant never receives less
invoice.fiat  # Fiat(amount='24.50', currency='CHF', minor=2450, digits=2)
invoice.rate  # RateInfo(source='coingecko', currency='CHF', price='69012', ...)

Price takes strings, never a float: an ISO 4217 code in upper case and an amount with at most the currency's decimals (the specification's table, currencies.json).

The price of one bitcoin comes from a rate source: "coingecko", "mempool" (USD, EUR, GBP, CAD, CHF, AUD, JPY), "coinbase", "kraken" or "opennode", or a function of your own. A list is asked in its order, the next source only when the previous one gives no usable price. Without rate_source, the client asks DEFAULT_RATE_SOURCES: coingecko, mempool, coinbase, kraken, opennode. A price its source dates must be less than an hour old.

from swissbitcoinpay_checkout import Client, RatePrice, UnsupportedCurrencyError


def my_rates(currency: str) -> RatePrice:  # or an async function
    if currency != "CHF":
        raise UnsupportedCurrencyError("no price", currency=currency)
    return RatePrice(price="69012.50", time=my_feed.updated_at)  # or just the decimal string


client = Client(key, rate_source=[my_rates, "kraken"], coingecko_api_key=os.environ.get("CG_KEY"))
invoice = client.create_invoice(ref="order-1044", fiat=Price("10", "CHF"), rate_source="mempool")

A custom source returns a decimal string or a RatePrice (never a float); a plain function runs in a worker thread, so it may block. An error of this package it raises goes through as it is; any other becomes RateError (its __cause__ kept). coingecko_api_key (CoinGecko's Demo key) is sent to CoinGecko only.

Payments and their states

get_status(invoice) checks the payment now; wait_for_payment(invoice, interval_ms=2000, on_status=...) checks it until it is paid or expired, handing each status to on_status (a function, or an async one), retrying NetworkErrors.

status.state Means
open Not paid yet; it may still be, until expires_at (reported up to 120 s later).
unconfirmed Enough came on-chain in time; fewer than 3 confirmations yet. Not paid yet.
paid Paid: method is lightning (preimage), onchain (txids) or mixed (both).
expired Never paid, for good. received above 0: an underpayment the merchant received.

status.to_json() gives the specification's JSON. received counts what came in time, in sats. A preimage (paid in Lightning) is the proof of payment: keep it.

On-chain payments

From 1 000 sats, an order may also be paid on-chain: the merchant's app gives the integration a lot of deposit addresses of its wallet (filled again whenever the merchant opens the app), and each order leases one for the time of its invoice. By amount:

  • within the Lightning address's bounds: a Lightning invoice, and from 1 000 sats an address too (invoice.payment_uri is then one BIP-21 QR code, bitcoin:…?amount=…&lightning=…);
  • above its bounds: on-chain only (OnchainUnavailableError when no address could be leased: retryable, once the merchant opened the app);
  • below 1 000 sats: Lightning only.

An address is the order's once its lease is confirmed: published, read again on every relay (no other order's lease of it, the read complete), then marked confirmed. A lease that is not confirmed (another server leased the address at once, or the read could not be complete) is withdrawn, and the next address tried; one that was confirmed is never withdrawn, whatever fails after (the checkout link, the declaration): it holds its address until its end. An address a witness of the integration names (a transaction it saw paying it) is never leased again.

An on-chain payment is unconfirmed, then paid at 3 confirmations seen by two explorers (mempool.space and Blockstream's), which takes about 30 minutes: wait while the customer pays, then check again later with get_status. A transaction replaced by another (RBF) keeps its order; one broadcast after the invoice's end (and its 120 s of grace) is late and pays no order. A transaction counts for one invoice at most: one that the witness of another invoice names counts for nothing.

Underpayments. When less than the amount came on-chain in time, the status stays open with status.rest: what is left (rest.sats), a Lightning invoice of exactly that (rest.bolt11, asked of the merchant's Lightning address and declared by the check itself) and one QR code for it (rest.payment_uri, on-chain too from 1 000 sats). Paid in time, the order is paid with method: "mixed". The merchant may accept an on-chain payment a little short of the amount (its tolerance, set in its app): read from the channel when the invoice is made, kept in the order's lease, never written where your page or the customer reads it.

wait_for_payment of an invoice with an address listens to mempool.space's WebSocket and checks the on-chain part when it says something changed (and every 60 s at least); get_status never opens it. A failure that each check of a wait meets again (an explorer or the Lightning address that does not answer, a rest invoice that cannot be asked, a witness that cannot be published) is logged once, and again only once a check went without it.

Servers of one shop run implementations of one version of the specification.

invoice = client.create_invoice(
    ref="order-1045", sats=49_000, checkout=True, redirect_url="https://shop.example/orders/1045"
)
redirect(invoice.checkout_url)  # the merchant's web app shows and checks the invoice

The customer pays on the merchant's web app, which sends them to redirect_url once paid (an https URL with a lowercase host, or an app's link such as myshop://orders/1045). CheckoutUnavailableError when the merchant's app gives no web app yet. Being sent to the redirect URL proves nothing (anyone can open it): mark the order paid only after get_status says paid. Give checkout_url to the order's customer only: it shows the invoice, the business's name and the note.

Errors

Every error is a SwissBitcoinPayError with a stable code and retryable (whether the same call may succeed later, unchanged); a message never holds a key. A cancellation is not one: the asyncio cancellation, or TimeoutError past a Client call's timeout.

Exception code Retryable What to do
InvalidKeyError invalid_key no The key is not an integration key: check the secret you loaded.
InvalidOptionError invalid_option no param names the client's or the call's option.
InvalidOrderError invalid_order no param names the order's parameter, or invoice (stored).
RevokedError revoked no The merchant removed the integration.
ChannelNotFoundError channel_not_found unless newer newer_format: update this package.
NetworkError network yes A relay or a service did not answer usably: try again.
InvalidInvoiceError invalid_invoice no The Lightning address's answer failed a check (check).
InvoicePendingError invoice_pending no Show invoice again (or checkout_url).
OrderPaidError order_paid no The order was paid: invoice, status, preimage.
UnsupportedCurrencyError unsupported_currency no No source has a price in currency.
RateError rate yes No usable price (invalid, too old, a custom source failed).
CheckoutUnavailableError checkout_unavailable no No checkout link for this merchant yet.
OnchainUnavailableError onchain_unavailable yes Only payable on-chain, and no address could be leased for now.

create_invoice returns an invoice only once it is declared on a majority of the merchant's relays (and its address leased, its checkout link published): after any error there is nothing to show. Call it again: the order gets its invoice, or InvoicePendingError with the earlier one.

Options

Client(key, ...) and AsyncClient(key, ...) take the same keyword options; an invalid one raises InvalidOptionError naming it, before any request.

Option Default Rule
rate_source DEFAULT_RATE_SOURCES A source, or a list of them (each name once).
coingecko_api_key none CoinGecko's Demo key: visible ASCII (U+0021 to U+007E).
relay_timeout_ms 10 000 Per relay request, connection included: 100 to 60 000.
http_timeout_ms 10 000 Per HTTP request, body included: 100 to 60 000.
http the standard library's http.client An HttpTransport (tests, proxies). Never follow redirects.
websocket websockets, 1 MiB a message, no redirect, no compression async (url) -> connection (tests, proxies).
logger logging.getLogger("swissbitcoinpay_checkout") Warnings only, never a secret.
now, random time.time, os.urandom For tests only.

Neither default transport uses the environment's proxy settings: to go through a proxy, inject your own http and websocket.

What goes where

  • The merchant's Nostr relays (the key's, or those the merchant's app names): the channel, the declarations, leases, witnesses and checkout links, all encrypted (NIP-44) and signed.
  • The merchant's Lightning address (breez.tips): asked for each invoice and its payment.
  • The rate sources you use: asked for a price, with your server's IP address.
  • mempool.space and blockstream.info (orders with an address): the address and its transactions; api.blockcypher.com: a transaction, only for one no check saw in time.

Never the key: every one of them sees your server's IP address and what it asks. Every HTTP request, and the opening handshake of each WebSocket, sends User-Agent: SwissBitcoinPay-Checkout/1, the same in every implementation and version of the specification.

Outbound requests. Besides fixed hosts (the rate sources, the explorers), your server asks hosts that others choose: the merchant's Lightning address and relays, and that address's provider (its callback, its verify URLs). No redirect is followed, and their URLs are checked first: no IP address, no localhost nor a name under it, TLS before any request. But a name may still lead to an address of your own network (an internal name, a public name resolving to a private address). If your server holds the keys of several merchants, or sits in a network with services of its own, restrict what it can reach to public addresses: in your network, or in the http and websocket you inject. The package does not resolve names itself to refuse private addresses: a name can resolve again to another address (DNS rebinding).

Dependencies

The standard library first: SHA-256 and HMAC (hashlib, hmac), the HTTP client (http.client, which never follows a redirect), JSON, base64, big integers. Three packages for the rest, each because the standard library has no such thing:

Package Why Rejected alternatives License
btclib-secp256k1 secp256k1: BIP-340 Schnorr signatures, x-only keys, ECDH. A binding (cffi) to libsecp256k1 v0.8.0. coincurve (its last release has no wheels for Python 3.14 and 3.15, and its sources did not build on 3.14), secp256k1 (no wheels after 3.13), pure-Python curves (never the curve written by hand: not constant time). MIT
cryptography HKDF and ChaCha20 (NIP-44 v2), on OpenSSL; maintained by the Python Cryptographic Authority, and already on most Python servers. pycryptodome (a second C cryptography library beside the one most servers carry), a ChaCha20 written by hand. Apache-2.0 OR BSD-3-Clause
websockets The Nostr relays and mempool.space's WebSocket, on asyncio, with a message limit. aiohttp (a whole HTTP stack the package does not need), wsproto (the I/O to write ourselves). BSD-3-Clause

Their own dependencies: cffi (MIT-0) and pycparser (BSD-3-Clause).

What "audited" rests on. No published audit covers btclib-secp256k1 itself: what it rests on is that it binds libsecp256k1, the library of Bitcoin Core, developed and reviewed in the open for Bitcoin, whose signing and key generation are constant time (its README). The release this package locks (0.8.0.10) builds libsecp256k1 v0.8.0: its sources in the package are identical to the upstream tag (commit 6e2c8bc), and every one of its 66 files on PyPI carries a PEP 740 attestation of its build by the btclib-org/btclib-secp256k1 repository's release workflow. The binding's own code (some 8 000 lines of Python around cffi) is that project's, far less used than libsecp256k1 itself: its pin is exact, and an update is read before it is taken. The package tests it with the official BIP-340 vectors and the official NIP-44 vectors (tests/vectors/, spec/vectors/nip44/).

Versions. uv.lock pins every package exactly, with its hashes, and the CI installs it as it is; no version less than 7 days old is ever locked (exclude-newer), and Dependabot waits as long. What the package declares to pip leaves the integrator's own resolver room where it is safe: btclib-secp256k1 exactly (few projects share it, and its fourth version number may break its interface), cryptography from 50.0.2 on (shared by many packages, and its security fixes must reach the merchant's server without waiting for a release of this one), websockets from 17.2 and below 18.

Known limits

  • Constant time. The curve operations (libsecp256k1) and the ciphers and MACs (OpenSSL) are constant time; MACs are compared with hmac.compare_digest. The Python code around them is not, and Python cannot erase a key from memory (its bytes are immutable): the key lives in the server's memory as long as the client does. Keep it in a server you trust.
  • Platforms. The CI runs every supported Python version on Linux (x86_64) and Windows (x86_64). macOS, ARM machines, PyPy and the free-threaded builds of Python are not tested by the CI (see Install for the wheels).
  • Waiting. An on-chain payment takes about 30 minutes to be paid: a server that cannot wait that long checks again later with get_status. wait_for_payment keeps one WebSocket open and opens new relay connections at each check.
  • Timeouts. Each relay request and each HTTP request is bounded by its timeout. A request that times out or is cancelled shuts its socket at once, wherever it is (connecting, TLS, reading); a name resolution under way in the operating system cannot be interrupted: its thread ends when the resolution returns, and makes no connection. A create_invoice cancelled (or past a Client call's timeout) once it published a lease, before that lease was confirmed, publishes nothing more: the lease is not withdrawn, never gives its invoice an address, and holds the address until its end (4 320 s at most).
  • Reads on every relay. Three reads decide that a record is nobody else's: a lease's confirmation, the witnesses read again after a check published one, and the choice among several invoices of an order. They wait for every relay of the merchant's list, one relay_timeout_ms after a majority answered at most, so that a relay that hides a record cannot hide it while another one that holds it answers in time; a relay that ran out of time, or lost its connection, earlier in the call is not waited for again. Every other read goes on at a majority.
  • Leases anybody can write. Anybody may write events where a lease is (its place is public): the leases of the lot are read page after page of 100 events, newest first, and a read they fill (one second holding more than a page, 20 full pages) is not complete; the order then goes without that address, never with one another order holds. It relies on relays that answer a filter's limit with their newest events, as many as the limit when they hold as many (strfry, nostr-rs-relay): the merchant's relays are such relays.
  • What third parties send. Every answer is checked before it is used, within the bounds the specification sets (1 MiB a message or an HTTP body, 1000 events a query, 20 pages). A read keeps of each event only what it needs, as the event comes (its id and date, and the record it opens to; of a lease, which anybody can copy under another id, a few bytes): what a relay sends never piles up, whatever its size, and an answer is never cut at a size (events anybody wrote where a lease is end a read of leases incomplete, never failed). The relays' WebSocket offers no compression, whose few bytes could unpack into many messages held at once. Parsing what is received runs on the event loop: a BOLT11 invoice of 1 MiB takes some 0.6 s of CPU, an explorer's answer of 1 MiB made for it about 1 s. mempool.space lists 75 transactions of an address at most (50 unconfirmed, 25 confirmed); a check publishes a witness, and may ask BlockCypher, for each one it has not seen yet, one after the other.

The specification

spec/ holds the files of the specification, unchanged, at commit d5dd0e9491423d03083662f2a621e7a9c8eea14a (version 0.1.0) with their SHA-256 in spec/PIN.json; tests/test_spec_pin.py fails on any difference, and every vector file is read by a test. To follow a new version:

python scripts/pin_spec.py /path/to/a/checkout-spec/clone <commit>

then update the code until the new vectors pass, in one pull request. A disagreement between the specification and this package is reported to checkout-spec (privately, by its SECURITY.md, for anything that could cost a merchant money or leak a key).

Development

With uv 0.13.0 (.github/ci-requirements.txt):

uv sync --locked
uv run --locked pytest            # never the network: local relays and fakes
uv run --locked ruff format --check . && uv run --locked ruff check .
uv run --locked mypy

Releases

Releases are the maintainers' only: a maintainer pushes a tag vX.Y.Z (the version of pyproject.toml) on a commit of main; release.yml runs the CI again, checks the tag, builds the package from the lockfile, and publishes it to PyPI by trusted publishing, in the pypi environment, once a maintainer approves.

License

MIT, © 2026 Swiss Bitcoin Pay Sàrl.

Metadata

Release files for swissbitcoinpay-checkout 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for swissbitcoinpay-checkout 0.1.0
File Size Uploaded
swissbitcoinpay_checkout-0.1.0.tar.gz 462.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for swissbitcoinpay-checkout 0.1.0
File Interpreter ABI Platform
swissbitcoinpay_checkout-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 590.5 kB

Release files / swissbitcoinpay_checkout-0.1.0.tar.gz

Download URL swissbitcoinpay_checkout-0.1.0.tar.gz
Size 462.8 kB
Tags Source
SHA-256 checksum
How to use checksums
d254097a14bf331bfb03539c3adc6a670b1c8cf3be327907fa053034de08d169
BLAKE2b-256 checksum
How to use checksums
f79161a9899b57911f56a331adf05bbb1f45407f0ef357879bc4f1348640697c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.

Transparency log

Release files / swissbitcoinpay_checkout-0.1.0-py3-none-any.whl

Download URL swissbitcoinpay_checkout-0.1.0-py3-none-any.whl
Size 127.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a32850934e2187452108dac8b2d560ec0628a22e97b1fcd340dd52a72c7b4bc9
BLAKE2b-256 checksum
How to use checksums
f3ed5531d8d5a60071b3a6ab6cdbbfc925ea6353d7e6e9fb7c374b4d110cccab
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page