Docs
Testing and CI.
Capturing a message is half the product. The other half is asserting on it from a test that fails for a readable reason when the message never arrives.
The assertion API
Authenticate with a project API key, as X-Api-Key: nsk_….
| Endpoint | What it does |
|---|---|
| POST /v1/messages/expect | Long-polls until a matching message arrives, then returns it. The one you want in tests. |
| GET /v1/messages/latest | The most recent match, or 404. Filters: channel, to, q. |
| GET /v1/messages | List with filters, including status. |
| GET /v1/messages/{id} | One message with its full provider payload and lifecycle events. |
| POST /v1/messages/{id}/reply | Simulate an inbound reply, so two-way flows can be tested. |
| DELETE /v1/messages | Clear the inbox, optionally one channel. Call it between tests. |
| GET /v1/stats | Counts per channel and status. |
| GET /v1/stream | Server-sent events as messages arrive. |
| GET/POST/DELETE /v1/templates | The DLT and WhatsApp template registry for this project. |
Python SDK
from notifysim import Sandbox sb = Sandbox(base_url="https://api.notify-sim.io", api_key="nsk_…") msg = sb.sms.expect(to="+919848012345", timeout=10) otp = msg.extract(r"\d{6}") assert msg.dlt_matched assert msg.delivered
sb.sms, sb.whatsapp, sb.email and sb.push each offer expect, latest, list and clear. A message exposes body, subject, html, status, error, provider_msg_id, dlt_content_id, template_match and the raw provider payload. A timeout raises ExpectTimeout, which subclasses AssertionError - so a missing message reads as a failed assertion, not a stack trace.
A test that would otherwise need a real gateway
import pytest from notifysim import Sandbox @pytest.fixture def sb(): s = Sandbox(base_url=os.environ["NOTIFYSIM_URL"], api_key=os.environ["NOTIFYSIM_KEY"]) s.clear() # each test starts on an empty inbox yield s def test_login_otp_is_dlt_compliant(client, sb): client.post("/login", json={"phone": "+919848012345"}) msg = sb.sms.expect(to="+919848012345", timeout=10) assert msg.dlt_matched, msg.template_match assert client.post("/verify", json={"otp": msg.extract(r"\d{6}")}).status_code == 200 def test_bounce_is_handled(client, sb): client.post("/invite", json={"email": "fail@example.com"}) assert sb.email.expect(to="fail@example.com").status == "failed"
In CI
Two ways, both fine. Run the local edition as a service container, or point at a hosted project. The local edition needs no credentials and no network, which is usually what you want on a pull request.
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Start Notify-Sim run: | docker compose -f notify-sim/docker-compose.yml up -d # wait for the sandbox to answer until curl -sf localhost:9925/health; do sleep 1; done - name: Test env: NOTIFYSIM_URL: http://localhost:9925 NOTIFYSIM_KEY: nsk_local SMTP_HOST: localhost SMTP_PORT: "1025" run: pytest -q
test:
variables:
NOTIFYSIM_URL: "http://notifysim:9925"
NOTIFYSIM_KEY: "nsk_local"
services:
- name: notifysim/local:latest
alias: notifysim
script:
- pytest -q
Keeping parallel runs apart
Two jobs asserting on the same inbox will steal each other's messages. Pick one of these:
- Address-scoped assertions. Give each test a distinct recipient - a phone number or address derived from the test name - and always pass to=. Cheapest, and works on a single project.
- Clear between tests. sb.clear() in a fixture, as above. Fine when tests within a job run serially.
- A project per CI run. Create one at the start of the job and delete it at the end - complete isolation, and it counts as one project against your plan while it exists.
- The local edition per job. A service container is its own universe. Nothing to clean up, nothing shared.
Receiving delivery receipts in a test
The sandbox fires the provider's own signed webhook shape at whatever URL your project registers, including inbound replies. In CI, register the address of a small receiver running inside the job - the signature is verifiable with the same code you use in production, which is the point.