service_account_metadata_required 400: which source headers to send

The 400 lists missing fields in details.missing and the header for each in details.headers. Five x-sume-source headers exist; send the ones named.

4 min readSume
All posts

service_account_metadata_required is a 400 that says your key's policy requires attribution headers and one or more are absent. The body tells you exactly which: details.missing lists the field names and details.headers maps each one to the header you should send. A key that has no required fields never sees this error.

The five attribution headers

The policy reads five single-value headers from the request. A policy can require any subset of them:

Service-account attribution headers (Sume API source, read 2026-10-05)
Field in details.missingHeader to send
source_productx-sume-source-product
source_environmentx-sume-source-environment
source_job_idx-sume-source-job-id
source_user_id_hashx-sume-source-user-id-hash
source_workspace_id_hashx-sume-source-workspace-id-hash

Why a 400 is correct, and why it is cheap

The check runs before the spend and queue checks and before any reserve, so a refused request costs nothing and creates no job. It is retryable: false, because the same request without the header will always fail. Add the headers and resend, with the same Idempotency-Key if you set one.

Two header values deserve care. The *_hash fields are meant for a hash, not a raw user or workspace id, so hash on your side and never send personal data. And source_user_id_hash has a second job: when a key has a per-end-user daily cap, that header is how Sume tells which user the spend belongs to.

Build the headers from the error

Instead of hard-coding the list, you can read the map from the first error and attach it to later calls. This sketch runs offline:

import json

BODY = '''{"error": {"code": "service_account_metadata_required",
 "details": {"missing": ["source_product", "source_job_id"],
  "headers": {"source_product": "x-sume-source-product",
              "source_job_id": "x-sume-source-job-id"}}}}'''

VALUES = {"source_product": "my-app", "source_job_id": "batch-17"}

d = json.loads(BODY)["error"]["details"]
headers = {h: VALUES[f] for f, h in d["headers"].items() if f in VALUES}
print(headers)

Common mistakes

The first is sending the headers only on the submit and not on the retry: the policy runs on each paid submit, so a retry without them fails the same way. The second is sending an empty string, which is treated as absent, because only non-empty values count as present. The policy reads a single value per header. Set each header once with a non-empty value, from one place in your code.

Tests worth writing

Write one test per required header: send the request with it removed and expect this 400, then send it back and expect the call to proceed. Add a test that your wrapper sends the same attribution on a retry. Keep the expected details.headers map in the test, so that a policy change on the key shows up as a clear diff and not as a surprise in production.

Where to put the logic

Add the headers in one client wrapper so every call carries them, including the polling reads, and keep the values stable per job so your own logs can group the work. Do not put secrets in them: they are labels. If you are unsure whether your key is a service-account key at all, the related posts on 403 insufficient_scope show the other places this shows up.

The general error shape is on the Errors and credits page.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume