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 retries — deduplicate 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_signaturedoes.
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.