Errors, retries & delivery
How MMOLove delivers reward callbacks — the retry schedule, delivery statuses, what counts as success, and how to troubleshoot failures.
A reward callback is delivered by a durable background worker, not inline with the vote. This page covers what "success" means, the retry schedule, the delivery statuses you'll see in the log, and how to debug a callback that isn't landing.
What counts as success
A delivery succeeds when your endpoint returns any 2xx status. Anything else
is a failure and will be retried:
| Your response | Outcome |
|---|---|
2xx | Delivered. No more attempts. |
3xx | Failure — redirects are not followed; point the callback URL at the final endpoint. Retried. |
4xx | Failure — retried. (A 4xx is almost always your signature check rejecting the request — see troubleshooting.) |
5xx | Failure — retried. |
| timeout / connection error / TLS error | Failure — retried. |
Return 2xx as soon as you've durably accepted the vote (recorded the
heart_id). If your reward grant is async, accept first and grant in the
background — don't hold the connection open, and don't return non-2xx unless you
genuinely want a retry.
Retry schedule
A failed delivery is retried with a fixed backoff, up to 6 attempts total. The delay before each retry:
| Attempt | Delay after the previous attempt |
|---|---|
| 1 | (immediate — on the vote) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| (after 6) | gives up — marked exhausted |
After the 6th failed attempt the delivery is marked exhausted and not retried
again. Because retries can span hours, your handler must be idempotent on
heart_id (a success whose 2xx was lost will be retried, and you must not
double-reward).
Don't rely on the exact delays — treat them as "retried for a while with growing
gaps." Do rely on: retries happen, they can be hours apart, and the same
heart_id can arrive more than once.
Delivery statuses
Each row in the Delivery log carries one of these statuses (the dashboard maps
them to ok / fail / retry chips):
| Status | Chip | Meaning |
|---|---|---|
pending | retry | Queued, waiting for its (next) attempt. |
sending | retry | A worker is delivering it right now. |
delivered | ok | Your endpoint returned 2xx. Terminal success. |
failed | fail | The last attempt failed; another retry is scheduled. |
exhausted | fail | All 6 attempts failed (or the delivery couldn't be sent) — given up. |
The chip also shows the last HTTP status code and the attempt count (3 tries), so
you can tell a 5xx-flapping endpoint from a signature-rejecting one at a glance.
Why a callback might never be sent
A delivery is only created when your integration is fully set up, and a few conditions cause MMOLove to skip or retire it before any HTTP request:
| Situation | What happens |
|---|---|
| No callback URL or no secret configured | No delivery is created — configure both on the Integration tab. |
| Integration not verified | No delivery is created until ownership is verified. |
| Callback URL isn't a public address | The delivery is skipped (SSRF guard): loopback, private, and link-local URLs are refused. Use a public https:// URL. |
The vote has no resolvable username | The delivery is retired as exhausted — a callback with no recipient can never reward anyone. Make sure voters enter an in-game name. |
Troubleshooting
My endpoint returns 4xx and the log shows fail/retries.
Your signature check is rejecting the request. This is overwhelmingly the
raw-body bug:
you verified against a re-serialized body, not the exact bytes. Capture the raw body
first, recompute HMAC_SHA256(secret, "<t>.<rawBody>"), and compare to v1. Also
check you're using the current secret (a rotate invalidates the old one).
The log shows request_failed / nothing arrives.
MMOLove couldn't reach your endpoint. Verify it's publicly reachable over HTTPS
with a valid certificate, isn't behind auth/IP-allowlisting that blocks us, and
isn't a redirect (we don't follow 3xx).
Deliveries pile up as retry.
Your endpoint is consistently non-2xx or timing out. Use Send test callback to
reproduce on demand, fix the handler, and the queued retries will then succeed on
their next attempt.
I rewarded twice.
Your grant isn't idempotent. Key it on heart_id so a retried delivery (or a lost
2xx) is a guaranteed no-op. See
Payload → idempotency.
Nothing in the log at all. No counted hearts yet — or the integration isn't verified / configured. Confirm the callback URL + secret are saved and click Send test callback to force a delivery.
See also
Testing & self-verify
Prove your reward-callback wiring with the dashboard "Send test callback" button and the delivery log — without waiting for a real vote.
PHP
A complete, copy-paste PHP reward-callback handler — read the raw body, verify the X-MMOLove-Signature, reward the voter, and stay idempotent on heart_id.