skip to content

What does a POST to OPA's /v1/data endpoint send and return?

level: juniorimportance: must knowfreq 74%

answer

  1. the facts travel in one wrapper
  2. URL mirrors the package path
  3. the answer is wrapped too
  4. 200 even when it says no

basics

~20 s

You POST to /v1/data followed by the document path, with a body of the form {"input": {...}} carrying the facts. OPA evaluates that document and replies 200 with {"result": <value>} - the value the rule produced.

solid answer

~40 s

The URL is a document path: a rule named `allow` in `package db.policy` is the document `data.db.policy.allow`, so you POST to `/v1/data/db/policy/allow`. The request body wraps your facts in a single field, `{"input": {...}}`, and inside the policy that object is the `input` document. OPA replies `200` with the answer wrapped the same way: `{"result": false}`, `{"result": true}`, an object, whatever the document evaluated to. The most important thing to say out loud is that the HTTP status is about evaluation, not about the verdict - a denial is a perfectly ordinary `200`, and a client that branches on the status code enforces nothing. A `GET` to the same URL evaluates with no input, which is fine for documents that depend only on data OPA already holds.

code

json · 9 lines
json
{
  "input": {
    "action": "create",
    "resource": {
      "type": "managed_database",
      "backup_retention_days": 7
    }
  }
}

go deeper

for a junior

Be ready to name the pieces without hesitating: a POST to /v1/data plus the document path, the facts wrapped in an input object, the answer wrapped in a result object. Say out loud that a denial still comes back as HTTP 200.

for a middle

Explain how the URL is built from the package and rule names, why the input you send and the data OPA already stores are different things, and what changes in the response when you query a whole package instead of one rule.

for a senior

Show that you design the calling service around the payload rather than the status code: state exactly what your enforcement point does on a 4xx, on a 5xx, and on a 200 whose body has no result in it.

for a principal

Own the question of which document path is the published interface for callers. Decide who is allowed to change its shape, and how a shape change reaches every caller without a window in which the gate quietly stops answering.

## Asking OPA a question over HTTP OPA answers one thing at a time: *what is the value of this document, given these facts?* Over HTTP that question is a request to the Data API, and every integration detail follows from its two envelopes - one around what you send, one around what comes back. ### The URL is a document path, not a file name Rego rules live in packages. A rule named `allow` inside `package db.policy` is the document `data.db.policy.allow`. On the wire the `data` root becomes the `/v1/data` prefix and the dots become slashes, so the URL is `/v1/data/db/policy/allow`. Several `.rego` files can contribute rules to the same package; the file names never appear in the URL and are irrelevant to the caller. You also choose how deep to point. `/v1/data/db/policy/allow` asks for one value. `/v1/data/db/policy` asks for the whole package document - an object with a key for every rule in it that is defined. Pointing at a single named decision is almost always the better client contract: the response stays one value with one meaning, instead of a snapshot of whatever the policy currently happens to contain, including helper rules a policy author is free to rename tomorrow. ### The facts go under `input` The POST body is `{"input": { ... }}`. Whatever you put inside that field is the `input` document the policy sees. Two nearby things it is not: - **`data`** is OPA's own stored state - documents loaded from a bundle or pushed with `PUT /v1/data/...`. That is the standing information the engine already has, like a list of approved database engines. It is not part of your request. - **The URL path** selects *what to evaluate*, not *what to evaluate it against*. Putting a resource id in the path does not make it visible to the rule. A `GET` on the same path evaluates the document with no input supplied, which only makes sense for documents that depend purely on stored data. A concrete example. Suppose the rule enforces that a managed database may not be created with a backup retention window under thirty days. The provisioning service, before it calls the cloud API, posts the spec it is about to submit: ``` POST /v1/data/db/policy/allow {"input": {"action": "create", "resource": {"type": "managed_database", "backup_retention_days": 7}}} ``` and gets `{"result": false}` back. It then refuses to make the call. ### The answer comes back under `result` A successful evaluation is `200` with `{"result": <value>}`. The value is whatever the document evaluated to - a boolean, an object, an array of messages. Two consequences get probed constantly: 1. **Status is about evaluation, verdict is in the body.** `200` means OPA ran the query without an error. `400` means it could not read your request, `500` means evaluation itself failed, and a connection error means the engine is not there. None of those are denials, and a denial is not any of those - it is a `200` whose body says `false`. Enforcement points that check `response.ok` and proceed are not enforcing anything. 2. **An undefined document has no `result` field at all.** You get `200` and the body `{}`. This is the single most expensive detail in the whole API, because the shape of a 'no decision' is indistinguishable at the transport layer from a healthy call. Requests can also ask for extras alongside the result - timing metrics, or an evaluation trace - which arrive as sibling fields in the same object. They are diagnostics; the decision is always the `result` field. ### v0 versus v1 shapes OPA also exposes `/v0/data/<path>`, kept for simple webhook-style callers that cannot reshape their payload. There, the **request body is the input document itself** (no `input` wrapper) and the **response body is the value itself** (no `result` wrapper). Same evaluation, different envelope. Mixing them up is a classic integration bug and it fails quietly in both directions. Point a v0-shaped caller at a v1 URL and there is no `input` field in the body, so the rule sees nothing and evaluates against an empty input. Point a v1-shaped caller at a v0 URL and the whole `{"input": ...}` object becomes the input document, so every reference in the policy is off by one level - the rule looks for `input.resource` and the actual value is at `input.input.resource`. Neither produces an error status; both produce wrong decisions. ### What to carry away The wire contract is small: path names the document, `input` carries the facts, `result` carries the answer, and the status code tells you only whether OPA could answer at all. Build the client around the body, decide up front what it does when the body has no `result` in it, and pin the exact path you query in a test so that a package rename does not silently turn your gate off.

  • The rule is named allow in package db.policy - what URL do you POST to?
    `/v1/data/db/policy/allow`. The package path becomes URL segments under the `/v1/data` prefix and the rule name is the last segment. Posting to `/v1/data/db/policy` instead returns the whole package document - an object holding every defined rule in the package - which couples your client to the policy's internal rule names.
  • What does a 200 status tell you about whether the action is allowed?
    Nothing at all. `200` means OPA evaluated the query without erroring. Allowed or denied lives entirely in the body's `result` value, and a denial is an ordinary `200`. Error statuses mean the engine could not answer - a malformed request, an evaluation error, an unreachable server - and none of them are a verdict.
  • How does the /v0/data shape differ on the wire?
    It has no envelopes. The request body *is* the input document and the response body *is* the value, which suits webhook callers that cannot reshape their payload. Sending a v1-shaped body there makes the policy see `input.input.*`, and every reference in the rule silently misses.

saying these in an interview costs you the question

  • Expects a 403 status when the policy denies
  • Puts the facts at the top level instead of under input
  • Thinks the URL path names the .rego file
  • Sends no input and assumes OPA already knows the request

context