Transports
Developers

Transports

Two ways to receive events: an open connection your app dials out on, or a signed web request we send to a URL you host.

Transports

Two ways to receive events. Identical bodies, identical guarantees, different plumbing. An app can hold subscriptions on both at once.

Socket Webhook
Direction your app dials out we POST to your URL
Public URL not needed required, HTTPS
Trust the authenticated connection HMAC signature per request
Retries none 8 attempts over ~4 hours
Missed while down yes no, within the retry window
Card interactions same connection separate response path
Auto-disabled no after 24h of continuous failure

The socket

Your service opens a WebSocket to us and holds it open. Nothing needs to be reachable from the internet.

wss://<your-host>/ws/bot/?token=jjb_example_not_a_real_token

The token goes in the query string because a WebSocket handshake cannot carry an Authorization header. Treat the whole URL as a secret: never log it, never put it in an error report, never paste it into a bug tracker.

Connecting

On success, the first frame is a greeting:

json { "type": "hello", "bot": { "id": "…", "name": "Deploy Notifier", "scopes": ["events", "chat:write"] } }

Getting hello is the proof your token was accepted. Do not consider yourself connected until it arrives.

After that, events:

json { "type": "event", "event": "ticket.created", "event_id": "…", "organization_id": "…", "project_id": "…", "data": { } }

Close codes

Code Meaning What to do
4001 The credential was refused — unknown, revoked, suspended app, or missing the events scope. Stop. Do not reconnect.
1000, 1001 Normal or going-away close. Reconnect with backoff.
Anything else Network trouble, a restart on our side, an idle connection reaped. Reconnect with backoff.

4001 is terminal

A revoked token will be refused identically on every attempt. An app that treats 4001 as "retry in a second" will hammer the platform forever, and the first person to notice is usually whoever is paying for the egress. Surface it, log it loudly, and let a human issue a new token.

The SDK enforces this: SocketApp.start() raises SocketAuthError on 4001 and reconnects on everything else.

Reconnecting

Exponential backoff with jitter. Double from a second, cap at a minute, and add a random factor — every app in the world reconnects at the same instant after a restart, and a synchronised herd turns a blip into an outage.

```python from juhjuh import backoff_seconds

backoff_seconds(0) # 1.0 backoff_seconds(3) # 8.0 backoff_seconds(9) # 60.0 — capped ```

Reset the attempt counter on a successful hello, not on a successful TCP connect. A connection that is accepted and then immediately closed is not a success, and counting it as one produces a tight reconnect loop.

Missed frames

There is no acknowledgement and no deadline — you read at your own pace, which is the point of the transport. The cost is that anything sent while you were disconnected is gone. Sweep the API on reconnect for whatever your app is responsible for, then let the stream keep you current.


Webhooks

We POST the event body to a URL you own. Use this when your app cannot hold a connection open — a function that only exists while handling a request.

Requirements:

  • HTTPS, with a valid certificate.
  • A public address. Private, loopback and internal addresses are rejected, both when you register the URL and again at delivery time. A host that resolves publicly today and privately tomorrow is checked again on every send.
  • Answer with a 2xx quickly. Do the real work elsewhere. Deliveries time out after 10 seconds, and a timeout is a failure that will be retried.

Headers

Header Contents
X-JuhJuh-Signature-256 sha256=<hex> HMAC of the raw body under your signing secret
X-JuhJuh-Delivery-Id One delivery, stable across all of its retriesdeduplicate on this
X-JuhJuh-Attempt Which attempt this is, counting from 1. The delivery id does not change with it
X-JuhJuh-Event-Id Shared by every delivery one action caused — correlate on this
X-JuhJuh-Event The event name, so you can route without parsing the body
X-JuhJuh-Timestamp Unix seconds at which we sent this attempt. Informational — it is not part of the signed material
Content-Type application/json

These header names and their meanings are frozen.

The signing secret

Returned once, when you create the webhook subscription:

python sub = api.create_subscription( events=["ticket.status_changed"], transport="webhook", url="https://example.com/hooks/juhjuh", ) sub["signing_secret"] # starts with whsec_ — store it now

It is not retrievable afterwards. To rotate, create a second subscription, verify against either secret while both are live, then delete the first.

Verifying a delivery

Verify before you parse, and verify against the raw bytes.

```python from juhjuh import verify_signature

def handle(request): raw = request.body # bytes, exactly as received if not verify_signature(SECRET, raw, request.headers.get("X-JuhJuh-Signature-256")): return 401 payload = json.loads(raw) ... ```

The two mistakes that matter:

  • Do not re-serialise. Parsing the JSON and dumping it again changes key order, separator whitespace and unicode escaping — all of which change the digest. Every legitimate delivery then fails verification, and the tempting fix is to stop verifying.
  • Do not compare with ==. A plain string comparison returns early on the first wrong byte, which leaks how much of a guess was right. Use a constant-time comparison; verify_signature does.

Worked example

Secret:

whsec_example_not_a_real_secret

Raw body, exactly these bytes:

json {"data":{},"event":"ticket.created","event_id":"8f14e45f-ea1a-4f3f-9c1c-2b3d4e5f6a7b","organization_id":"9d1c8a2e-0f77-4a55-8e21-6b0c4d9f1a33","project_id":null}

HMAC-SHA256 of those bytes under that secret, hex-encoded and prefixed:

X-JuhJuh-Signature-256: sha256=0deede4b2be0ad9dd6086dab2f3cd63c72db6ef2b8b23629f73686f1338cb9d9

In any language:

python import hmac, hashlib expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() ok = hmac.compare_digest(expected, header_value)

If your implementation produces that digest for that body and secret, it is correct. If it does not, you are almost certainly hashing a re-encoded body.

Deduplicating

```python from juhjuh import SeenDeliveries

seen = SeenDeliveries()

if seen.is_duplicate(request.headers["X-JuhJuh-Delivery-Id"]): return 200 # already handled; acknowledge and stop ```

Remember this is per process. Across several replicas, back it with a shared store — a retry rarely lands on the replica that saw the first attempt. Where you cannot, make the handler idempotent instead: an app that only ever writes the same state twice does not need to remember anything.

The retry ladder

Retried: 5xx, 429, timeouts, connection failures. Not retried: any other 4xx. That is your endpoint saying it rejected the body, and losing the same argument eight more times helps nobody.

Attempt Roughly after
1 immediately
2 30s
3 1m
4 2m
5 4m
6 8m
7 16m
8 32m, then up to an hour between the last attempts

Eight attempts spanning about four hours — long enough to ride out a deploy or a short outage, short enough that an app coming back finds its events still arriving. After that the delivery is dropped. Every attempt is recorded, so the workspace can answer "did the app actually get told?" without guesswork.

Auto-disable

A subscription that fails continuously for 24 hours is switched off, and the reason appears on the workspace's Apps & Bots page.

The clock starts at the first failure of the current streak and is reset by any success, so an endpoint that fails intermittently is never disabled — only one that is genuinely gone. Re-enabling is a click, once the endpoint is back.

Without this, a dead endpoint would be retried forever and the customer's first signal would be a bill.


Choosing, in one paragraph

Take the socket unless you cannot hold a connection. It has less to get wrong: no certificate, no public endpoint, no signature to verify, no retry semantics to reason about, and card interactions come back on the connection you are already reading. Take webhooks when your app is a function, when your platform forbids long-lived outbound connections, or when you already have webhook plumbing that works and would rather have one more source feeding it.