OPNsenseXLII · API and AutomationAPI operations
API safety and rate limiting — staged writes, idempotency, and the operational guardrails
What you'll learn
- Describe the four modes an API call can have (read-only, staged write, apply, direct action) and choose the right one for a given operation
- Record the configuration revision to roll back to before a change-set, and revert to it on failure
- Apply timeout and retry discipline to long-running API workflows
- Distinguish a change that failed to save from one that saved but was never applied
Prerequisites
Verified against OPNsense 25.x · FreeBSD 14.x · PF (FreeBSD packet filter) FreeBSD 14.x · Unbound 1.20+ · Kea DHCP OPNsense 25.x plugin · WireGuard in-kernel + OPNsense plugin · strongSwan (IPsec plugin) OPNsense 25.x plugin · OpenVPN 2.6.x · Suricata 7.x · 2026-08-14
An API call that modifies configuration is a small mutation against a stateful system. The mutation may fail validation and change nothing; it may save and never be applied; it may be applied and fail to compile; it may race with another writer and be silently overwritten. The discipline that keeps automation safe is not “the call will succeed” — it is “every step has a defined outcome and a defined recovery path”.
This lesson covers the four modes an API call can run in (read-only, staged write, apply, direct action), the configuration revision that serves as the rollback point, the retry and timeout discipline, and the way to tell a change that never saved from one that saved but was never applied.
The four call modes
Every API call has a mode, even if the operator has not named it:
- Read-only.
GETon asearch,getorstatuscommand. Returns data; never modifies state. Safe to retry, safe to parallelise, no rollback path needed. - Staged write. A
setoraddcommand. It changes the configuration and records a new revision, but nothing in the data plane moves. Reversible by reverting the revision, and — importantly — invisible to traffic until applied. - Apply. The
applyorreconfigurecommand that pushes the staged configuration into the running service. This is the call with operational consequences, and the one to place deliberately rather than after every write. - Direct action. A command that does something immediately and has no staged half — killing states, restarting a service, running a diagnostic. These have no revision to revert; the recovery is whatever the operator does next.
A disciplined automation script distinguishes the modes by design. Reads are free. Staged writes are batched and cheap to undo. The apply is the moment the change becomes real, so it happens once, deliberately, after every staged write in the set has succeeded — and the revision to roll back to is recorded before the first of them.
$ curl -sk -u "$KEY:$SECRET" https://localhost/api/core/backup/backups/this | jq '.items[0]'{
"time": "1786000354",
"time_iso": "2026-08-14T09:12:34+00:00",
"description": "/api/firewall/filter/addRule made changes",
"username": "automation-firewall-rules",
"filesize": 184213,
"id": "config-1786000354.xml"
}Illustrative output
Transactional success vs partial failure
The framework splits a configuration change into two calls, and understanding the split is what makes automation safe.
The save call — addRule, setRule, addItem, and their equivalents — does three things:
- Validate the request body against the model schema.
- If valid, write the object into
/conf/config.xmland record a new configuration revision. - Return
{"result": "saved"}with the object’s UUID.
Nothing in the data plane has changed at this point. The apply call — filter/apply, alias/reconfigure, and their per-service equivalents — is what regenerates the configuration files from the templates and reloads the service. If the generated ruleset does not compile, the apply reports the failure and the service keeps running the configuration it already had.
| Save | Apply | State of the firewall |
|---|---|---|
200, saved | not called | Configuration changed, behaviour unchanged |
200, saved | success | Configuration and behaviour both changed |
200, saved | failure | Configuration changed, behaviour unchanged, and the two now disagree |
| 4xx validation | not reached | Nothing changed |
The third row is the partial-failure mode, and it is the one worth designing against. It looks identical to the first row from the outside — an operator reading the GUI sees the new rule in both cases — so the only way to tell them apart is to check what the firewall is running rather than what it is configured to run.
The saving grace is that this split is also the recovery mechanism. Because a failed apply leaves the running configuration untouched, a change-set that fails at apply has not broken anything yet; reverting the configuration revision and re-applying returns both halves to the known-good state.
$ curl -sk -u "$KEY:$SECRET" -X POST https://localhost/api/firewall/filter/apply | jq .{
"status": "OK
"
}Illustrative output
Timeout, retry and backoff
Three rules for any workflow that loops over API calls:
- Set a timeout on every call.
curl --max-time 30(or the equivalent in every HTTP library). The call should never block the script for more than a reasonable interval; if the firewall is hung, the script should fail fast rather than wait for the operating system to time out the socket. - Retry reads, not writes. A read that fails is safe to retry — the state it asks about will still be there. A write that fails may have applied silently before the error response; retrying risks creating the same change twice. Idempotent operations (an
addof an object that does not yet exist) can be retried with a UUID check; non-idempotent operations (asetof an existing object) cannot. - Back off on 5xx, never on 4xx. A 5xx or a refused connection says the firewall could not serve the request — the web server has a finite pool of PHP workers, and an apply occupies one for as long as the reload takes. Exponential backoff (1s, 2s, 4s) is the polite response. A 4xx is a client error: the request was understood and rejected, so retrying it unchanged produces the same rejection.
Detect, pause, rollback: the three-state discipline
For change-sets that touch more than one object, the discipline is:
- Detect. Before the change-set, capture the current state —
searchRulereturns the table,getRule/{uuid}returns a specific object — and record the current configuration revision id frombackups/this. - Pause. Between each staged write, check the response. A
savedresult with a UUID means proceed; a 4xx means stop. Because nothing has been applied yet, stopping here costs nothing but a revert. - Rollback. If any write fails, or the final
applyreports anything other than success, revert to the recorded revision and re-apply. Do not continue writing; a half-staged change-set that gets applied is worse than one that was never started.
The ordering matters: staging everything and applying once means a failure part-way through the change-set never reaches traffic. A script that applies after every individual write gives up that property and exposes each intermediate state to the network.
Reading state during a write storm
A monitor that reads while a writer is busy sees whatever the framework has committed at the moment of the read. The reader may see the state before, after, or partway through a multi-step change-set. The reader’s responsibility is to be tolerant of inconsistent snapshots — the GUI shows the same transient state, so this is not specific to API readers.
Three defensive reads:
- After each staged write, re-read the affected object by UUID and confirm the values are what you sent. This catches fields that were silently dropped because the model does not define them.
- When a verification fails, log what was expected and what was found before retrying. “The rule is missing” and “the rule is present with an empty description” are different failures with different causes, and a retry loop that only checks presence will not tell them apart.
- Distinguish reads of the configuration from reads of the running state.
searchRuleanswers “what is configured”;pfctl -sranswers “what is running”. During a change-set the two legitimately disagree, and a monitor that treats the disagreement as an alert will page on every routine change.
Summary
- Every API call has a mode: read-only, staged write, apply, or direct action. Choose deliberately.
- OPNsense writes a configuration revision on every change automatically. Record the current revision id from
GET /api/core/backup/backups/thisbefore a change-set; roll back withPOST /api/core/backup/revertBackup/{id}. - A
savedresponse means the configuration changed and the data plane did not. The separateapplyis what makes the change real, and its result is the gate on the change-set. - Stage every write in the set, then apply once. A failure part-way through then never reaches traffic.
- Retry reads, not non-idempotent writes. Set timeouts. The framework serialises individual writes but not whole change-sets, so keep one change-set in flight at a time.
Knowledge check · 4 questions
Q1. You are about to add 14 firewall rules through a series of API POSTs. What is the first call you should make?
Q2. A rule that POST /api/firewall/filter/addRule reports as saved is recorded in /conf/config.xml but has no effect on traffic until a separate apply call runs.
Q3. Which of these are correct operational guardrails for an API-driven change-set? Select all that apply.
Q4. A change-set stages 14 rules and applies once at the end. The fourth addRule returns 400 with a validation error. What is the state of the firewall, and what should the script do?
Passing score: 75%. Answers are checked in this browser.