Vortos
Paddle Billing

Outbox

How the Paddle transactional outbox works — delivery, backoff, status tracking, and dead-letter retry.

Outbox

All Transactional and Standalone service calls queue the Paddle API call in the paddle_outbox table rather than calling Paddle directly. The relay worker reads these rows and dispatches them to Paddle — decoupling your business transaction from the external API call.

How It Works

BEGIN TRANSACTION (your command handler)
  1. Write domain row to your tables
  2. Write outbox row to paddle_outbox (status=pending)
COMMIT

[Relay worker — separately]
  3. SELECT pending outbox rows
  4. Call Paddle API via ImmediateXxxService
  5. SET status=delivered (success) or retry/fail (error)

If the database transaction rolls back, the outbox row rolls back with it. The Paddle API is never called for work that did not commit.

Outbox Table

ColumnDescription
idAuto-increment primary key.
operationOperation name, e.g. customer.create, subscription.cancel.
payloadJSON-encoded parameters.
idempotency_keyUUID v7 — prevents duplicate writes to the same row.
statuspendingdelivered (success) or failed (permanently failed).
attemptsDelivery attempt count.
last_errorError message from the most recent failed attempt.
next_attempt_atScheduled time for the next delivery attempt.
delivered_atTimestamp set when the row is successfully relayed.
failed_atTimestamp set when all attempts are exhausted.
created_atWhen the outbox row was written.

Relay Command

Run as a long-running worker (recommended):

php bin/console vortos:paddle:outbox:relay

Run a single batch and exit (for cron):

php bin/console vortos:paddle:outbox:relay --once

Control the idle poll interval:

php bin/console vortos:paddle:outbox:relay --sleep=5

The worker handles SIGTERM and SIGINT gracefully — it finishes the current batch before exiting.

Backoff Strategy

Failed deliveries use exponential backoff:

AttemptBackoff (default 60s base)
160 seconds
2120 seconds
3240 seconds
4480 seconds
5960 seconds → capped at backoffCapSeconds (default 1 hour)

Rate-limit responses (429) use Paddle's Retry-After header directly instead of the exponential formula.

Configure via config/paddle.php:

$config->outbox()
    ->maxAttempts(5)
    ->backoffBaseSeconds(60)
    ->backoffCapSeconds(3600);

Dead-Letter Retry

When a row exhausts all attempts it is marked status=failed. The relay no longer picks it up. Use the retry command to inspect and reset these rows:

Inspect failed entries

# Show all permanently failed entries
php bin/console vortos:paddle:outbox:retry --dry-run

# Filter by operation
php bin/console vortos:paddle:outbox:retry --dry-run --operation=subscription.cancel

# Inspect a specific entry
php bin/console vortos:paddle:outbox:retry --dry-run --id=42

Reset for re-delivery

# Reset all failed entries (prompts for confirmation)
php bin/console vortos:paddle:outbox:retry

# Reset without confirmation
php bin/console vortos:paddle:outbox:retry --force

# Reset only a specific operation
php bin/console vortos:paddle:outbox:retry --operation=customer.create --force

# Reset a single entry
php bin/console vortos:paddle:outbox:retry --id=42 --force

# Limit how many rows are reset at once
php bin/console vortos:paddle:outbox:retry --limit=10 --force

Resetting sets status=pending, clears failed_at, last_error, and delivered_at, resets attempts=0, and sets next_attempt_at=now. The relay picks the rows up on its next cycle.

Why entries fail permanently

Entries reach status=failed when all retry attempts are exhausted. Common causes: the Paddle resource no longer exists (e.g. a subscription already cancelled), invalid payload, or a persistent Paddle API outage. Check last_error to understand the failure before resetting.

Status Reference

StatusMeaning
pendingWaiting for delivery or scheduled for retry.
deliveredSuccessfully relayed to Paddle.
failedAll attempts exhausted — manual retry required.

On this page