Skip to content

HTTP API (rl.php)

rl.php is the low-level endpoint. It is reachable directly from any client that can make an HTTP POST: browser pages, native mobile apps, server-side workers, other CMSes, edge functions. Deciding is not exposed here; it happens in PHP at render time as described in the PHP API.

Supported actions

All actions are additive; adding batch did not deprecate the legacy form actions, and rl_sorting and other production consumers keep using them unchanged.

Action Encoding Purpose
ping form POST Liveness check. Returns pong.
turn form POST Record one impression.
turns form POST Record impressions for many arms in one experiment.
reward form POST Record one conversion.
batch JSON POST Record turns and rewards across many experiments in one request. Used by Drupal.rl.

Experiment IDs and arm IDs must match ^[a-zA-Z0-9_-]+$. Experiments must already be registered via ExperimentRegistryInterface::register(); unknown IDs are silently dropped so garbage writes cannot create registry entries.

Legacy form actions

# Turn
curl -X POST https://example.com/modules/contrib/rl/rl.php \
  -d 'action=turn&experiment_id=hero_cta&arm_id=v0'

# Multiple arms in one experiment
curl -X POST https://example.com/modules/contrib/rl/rl.php \
  -d 'action=turns&experiment_id=hero_cta&arm_ids=v0,v1'

# Reward
curl -X POST https://example.com/modules/contrib/rl/rl.php \
  -d 'action=reward&experiment_id=hero_cta&arm_id=v0'

Batch action

POST /modules/contrib/rl/rl.php?action=batch
Content-Type: application/json

{
  "decides": [
    {"id": "hero_cta", "arms": ["v0", "v1", "v2"]},
    {"id": "faq_sort", "arms": ["t0", "t1", "t2", "t3"], "rank": true}
  ],
  "turns": [
    {"id": "hero_cta", "arm": "v0"},
    {"id": "menu_main_5", "arm": "v1"}
  ],
  "rewards": [
    {"id": "hero_cta", "arm": "v0"}
  ]
}

All three sections are optional. Invalid or unregistered entries are rejected individually without poisoning the rest of the batch. A successful response looks like:

{
  "ok": true,
  "decisions": {
    "hero_cta": {"armId": "v1"},
    "faq_sort": {"armId": "t2", "ranking": ["t2", "t0", "t3", "t1"]}
  }
}

decisions contains only entries that had a successful Thompson Sampling lookup. Missing keys mean "use the default variant". When "rank": true is set on a decide entry, the response includes a ranking array with all arm IDs sorted by Thompson Sampling score (best first). The armId field is always present and equals ranking[0] for backwards compatibility. Turns and rewards are fire-and-forget writes with no per-event response.

When some entries are rejected (invalid IDs, unregistered experiments, malformed entries), the response includes an errors array alongside any successful results. A partial success returns HTTP 200 with ok: true:

{
  "ok": true,
  "decisions": {
    "hero_cta": {"armId": "v1"}
  },
  "errors": [
    {"kind": "decide", "id": "unknown_exp", "reason": "unknown_experiment"},
    {"kind": "turn", "id": "bad!", "reason": "invalid_id"}
  ]
}

Each error includes the kind (decide, turn, or reward), the id that failed, and a machine-readable reason. When every entry in the batch is rejected, the response is HTTP 422 with ok: false and no decisions.

Error responses

Status When
400 Missing/invalid action, malformed JSON, or missing experiment_id on a legacy action.
422 Batch request where every entry was rejected (all invalid or unregistered).
500 Drupal kernel failed to boot. Error logged to the PHP error log.

Performance notes

rl.php bootstraps a minimal Drupal kernel per request (same pattern as core's statistics.php), not the full stack that would run behind a normal route. One kernel boot processes the whole batch, so the cheapest way to use this endpoint is to send as many events as possible in one request. Drupal.rl already does this on the browser side; non-browser callers should coalesce events similarly when they can.