Python SDK
The rasket package on PyPI: a typed client for Python 3.10+, built on httpx. Its request and response types are generated from the same document the API is validated against, and its methods from the Node client's own route map, so the two cannot drift apart.
Install
pip install rasket
# or, in a uv project
uv add rasketTwo runtime dependencies: httpx and typing_extensions. The package ships its type information, so mypy and Pyright check your calls against the API's own schemas.
The first send
import os
from rasket import Rasket
rasket = Rasket(
api_key=os.environ["RASKET_API_KEY"],
user_agent="acme-billing/1.0",
)
result = rasket.emails.send(
{
"from": "Acme <billing@acme.example>",
"to": ["ronald.williams@example.com"],
"subject": "Your receipt",
"html": "<p>Thanks.</p>",
},
idempotency_key="receipt-1042",
)
print(result.body["id"])The client sends a User-Agent of its own on every request, because a request without one is refused; whatever you pass as user_agent is appended to it, never substituted. Keys start with rk_, and an OAuth access token works wherever a key does. On a deployment host, pass base_url="https://<deployment-host>/api/v1".
What every call returns
result = rasket.emails.get(email_id)
result.body # the parsed body, typed per route
result.rate_limit # RateLimit(limit=10, remaining=..., reset_seconds=...)
result.request_id # the x-request-id to quote
result.idempotent_replayed # True on a replay of an earlier keyed requestOne shape for every method. For a list route, body is the { object, has_more, data } envelope described under pagination.
The same methods as the Node SDK
Every method of the Node SDK exists here under the same path, each segment in snake_case. Path parameters come first, then body or query as a dict; idempotency_key is a keyword.
| Node | Python |
|---|---|
emails.send | rasket.emails.send(body, idempotency_key=...) |
apiKeys.create | rasket.api_keys.create(body) |
webhooks.rotateSecret | rasket.webhooks.rotate_secret(webhook_id) |
templates.get | rasket.templates.get(template_id) |
emails.receiving.attachments.list | rasket.emails.receiving.attachments.list(email_id, query) |
Received mail
page = rasket.emails.receiving.list({"limit": 20}).body
message = rasket.emails.receiving.get(page["data"][0]["id"]).body
files = rasket.emails.receiving.attachments.list(message["id"]).body
for file in files["data"]:
# A part we would not store is listed without a link; check before you follow it.
if "download_url" not in file:
continue
download(file["download_url"])
rasket.emails.receiving.remove(message["id"])emails.receiving is its own resource, not a filter over the mail you sent: a received message is never an emails row, and neither read accepts the other's IDs. The two download routes are deliberately not on this client — they answer bytes rather than JSON, so follow download_url with any HTTP client.
Idempotency
Pass idempotency_key to emails.send or batch.send — the two routes that take one. The client sends it verbatim and never generates a key of its own: a key it invented would make a retry look idempotent when you never asked for that.
- 1–256 characters. A key outside that range is refused before any request is sent, as the same
400 invalid_idempotency_keythe API would answer, witherror.source == "client". - Scoped to your team, remembered for 24 hours.
- A replay of a finished request returns the original response with
idempotent_replayedset toTrue; a different payload under the same key is409 invalid_idempotent_request.
Errors
from rasket import RasketApiError, RasketConnectionError
try:
rasket.emails.send(message)
except RasketApiError as error:
if error.name == "validation_error":
reject(error.errors) # (ValidationIssue(path=..., message=...), ...)
elif error.name == "rate_limit_exceeded" and error.rate_limit:
later(error.rate_limit.retry_after_seconds)
else:
raise # error.status_code, error.message, error.request_id
except RasketConnectionError as error:
... # error.reason: "network" or "timeout" -- no answer arrivederror.name is the API's stable vocabulary, not a class name — the same names the errors page lists. The one value outside it is unknown_error: the response carried no error body of ours, and status_code is whatever came back.
Retries
| Request | Retried on a 429 or 5xx? |
|---|---|
GET | Always |
emails.send and batch.send with an idempotency_key | Yes: the key is what makes a repeat a replay |
emails.send and batch.send without one | Never: at-most-once matters more than a saved round trip |
| Every other write | Never |
retry-after is honoured when present, and a value above max_delay is not waited for — the error is raised at once with the header on it. Otherwise the wait is exponential with full jitter. A quota 429 is never retried, and neither is a 501; a 409 concurrent_idempotent_requests on a keyed send is, because it means your earlier attempt is landing.
from rasket import Rasket, RetryOptions
rasket = Rasket(
api_key=api_key,
timeout=30.0,
retry=RetryOptions(attempts=3, min_delay=0.5, max_delay=8.0),
)attempts=1 turns retrying off. timeout and both delays are in seconds, and the timeout applies per attempt.
Verifying a webhook
rasket.webhooks.verify checks a delivery's signature and returns the parsed event, or raises WebhookVerificationError with a reason. The same function is reachable from a client as client.webhooks.verify.
import os
from flask import Flask, request
from rasket import WebhookVerificationError, webhooks
app = Flask(__name__)
@app.post("/webhooks/rasket")
def receive():
raw_body = request.get_data() # the bytes, never request.json
try:
event = webhooks.verify(raw_body, request.headers, os.environ["RASKET_WEBHOOK_SECRET"])
except WebhookVerificationError:
return "invalid signature", 400
handle(event)
return "", 200The signature covers the raw request body. A framework that parses JSON before your handler has already destroyed what was signed — read the bytes first: request.get_data() in Flask, await request.body() in FastAPI.
Worth knowing
- The client is synchronous. Close its connection pool with
rasket.close(), or use it as a context manager; passhttp_client=to bring your ownhttpx.Client. - Timestamps are ISO 8601 UTC with milliseconds, everywhere.
scheduled_attakes ISO 8601 only; natural language is a400.webhooks.getreturns the signing secret masked. It is in thecreateandrotate_secretresponses and nowhere else.