Notify-Sim sandbox

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_….

EndpointWhat 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

pip install notifysim
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

tests/test_otp.py
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.

.github/workflows/test.yml
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
.gitlab-ci.yml
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:

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.

Registration refuses private, loopback, link-local and cloud-metadata addresses on the hosted service, so a sandbox cannot be pointed at something inside our network. The local edition lets you allow them, because there the network is yours.