Mocking the sides

Mock sink

The right side — see exactly what is delivered, then make it fail on command.

A sink that always says yes proves almost nothing. Retry, backoff and the dead letter queue are only observable if the receiving end can fail on purpose.

The mock sink is a stand-in consuming application that does exactly that. It runs on localhost:3004 with a console at /ui.

Start it and point the Center at it

cd sink-test
npm install
cp .env.example .env
npm run start:dev

Then set your SINK integration's endpoint URL to:

http://localhost:3004/sink

Press Test on the integration — it should go green. (The mock answers the reachability ping on HEAD /sink, which is what that button uses.)

Now send something with the mock source and watch it land.

See what actually arrived

localhost:3004/ui is a live feed — deliveries are pushed as they arrive, not polled.

The header is also the control panel: the behaviour mode, the fallback downlink and the active rules are switchable without leaving the page. The counters — deliveries, downlinks, failed, dupes, bad signature — are the fastest read on whether a test did what you expected. Here two rows answered 503 because the sink was in fail mode, and two are flagged DUP.

Click any row for the whole exchange: headers, request body, response body, with the signature verdict spelled out in words.

The banner at the top is there because this is a DATA_BIDIR with ack: true — the one kind of delivery whose response is load-bearing. Below it, the downlink that went back and whether a rule chose it or the fallback was used; then the request metadata, including the idempotencyKey your side should be de-duplicating on.

Two details make it more useful than a terminal:

  • Rows are labelled by messageType — the one name the Center uses for a message, which for Sigfox is the callback (DATA_UPLINK, DATA_BIDIR, …) — uplink — so a list keyed on it cannot tell you which callback arrived, which is exactly what you need to know when a downlink is involved. The row says DATA_BIDIR (0/3).
  • awaits reply marks a delivery whose response is load-bearing — a DATA_BIDIR with ack: true, where the device is holding its window open.

Filter by downlinks, or by problems — anything that was not a clean accept: a non-2xx, a duplicate, or a bad signature.

The log is on HTTP too:

curl -s localhost:3004/log
curl -X DELETE localhost:3004/log

Make it misbehave

One endpoint switches the behaviour at runtime:

curl -s localhost:3004/control                      # what is it doing now?
curl -X POST localhost:3004/control \
  -H 'content-type: application/json' \
  -d '{"mode":"fail","failStatus":503}'

Four modes, and what the Center should do in each:

ModeThe sink doesExpect the Center to
ok200 on everythingMark it delivered
failReturns failStatus (default 500)Retry with backoff, then dead-letter it after eight attempts — keeping the body so it can be replayed
slowAnswers after slowMs (default 20000)Hit the sink adapter's 15s timeout — a transport error, which is a different retry path from a 5xx
flakyFails flakyFailures times, then succeedsMark it delivered with more than one attempt — not dead-lettered

Switching mode resets the flaky counter, so a second flaky run does not start already used up. A mode change never blanks settings you did not mention.

A worked example: watch a dead letter appear

# 1. Make the sink refuse everything
curl -X POST localhost:3004/control -H 'content-type: application/json' \
     -d '{"mode":"fail","failStatus":503}'

# 2. Send a message
curl -X POST localhost:3003/emit/bidir

# 3. Watch Analytics: Failed climbs as attempts are retried.
#    After eight, the delivery lands in Dead letters.

# 4. Fix the "outage"
curl -X POST localhost:3004/control -H 'content-type: application/json' -d '{"mode":"ok"}'

# 5. Open Dead letters in the Center and hit Replay — it now succeeds.

That loop is the whole delivery contract in five commands: at-least-once, backoff, dead letter, replay.

A worked example: prove retry is not data loss

curl -X POST localhost:3004/control -H 'content-type: application/json' \
     -d '{"mode":"flaky","flakyFailures":2}'
curl -X POST localhost:3003/emit/bidir

The first two attempts fail, the third succeeds. The message is delivered, not dead-lettered — and the delivery record shows more than one attempt. This is what a brief sink outage looks like when nothing is actually wrong.

`slow` is not the same test as `fail`
A 5xx is an answer; a timeout is the absence of one. They take different paths through the retry logic, and a sink that hangs is far more common in production than one that cleanly returns 500. Test both.

When a delivered message asked for a downlink, the reply to that same request is what answers the device. So the mock sink can act as the far end of a downlink bridge with no extra wiring.

Set a fallback answer:

curl -X POST localhost:3004/control -H 'content-type: application/json' \
     -d '{"downlinkHex":"deadbeef00000001"}'

The hex must be exactly 16 characters — Sigfox downlinks are 8 bytes — or the Center discards it.

Let the message decide

A real application does not reply with a constant; it looks at what the meter reported. Rules model that: when all these conditions hold, answer with this hex. First match wins.

curl -X POST localhost:3004/control -H 'content-type: application/json' -d '{
  "downlinkRules": [
    { "name": "low battery", "downlinkHex": "deadbeef00000001", "when": [
      { "path": "messageType", "op": "eq", "value": "DATA_BIDIR" },
      { "path": "ack", "op": "eq", "value": true },
      { "path": "payload.alarms.LowBattery", "op": "eq", "value": true }
    ]}
  ]
}'

Operators are eq, neq, gt, lt, contains, exists and missing. Values are compared loosely by type, so the boolean true matches the text true.

Paths depend on your output structure
The same alarm can arrive at payload.alarms.LowBattery, at telemetry.alarms.LowBattery, or not at all — it depends on the output structure set on that source → sink pair. A rule written against a guessed path simply never fires. The console records every path it has actually received and suggests them, showing the last value beside each, so write rules from that list rather than from memory.

You can edit all of this in the console instead — downlink rules → edit — where the behaviour mode and fallback hex are switchable from the header too.

Where each console stops

The mock sink tells you what the application received. It cannot tell you whether a downlink it offered actually reached the device — that is the mock source's console, which shows the round trip from the device's side.

Use them together: one end proves what was sent, the other proves what came back.

In the cloud

The same rig runs on Dokploy at sink.iot.una.center: the production Center's delivery workers POST to https://sink.iot.una.center/sink exactly as they would to a customer's endpoint, and the console is at /ui. Its log lives in memory — a redeploy empties it. It is a public endpoint that answers ok to anything: stop the app when nobody is testing.

Next

Mock source
The other side: driving your pipeline without hardware.
Running the whole loop
Both ends at once: a round trip, a dead letter, a replay.
Copyright © 2026