Papers

Bokbasen ingestion — wire-level API contract

All Confluence URLs accessed via the Confluence REST API (/wiki/rest/api/content/<id>?expand=body.storage) on 2026-04-30. Page IDs are the canonical identifiers; human-readable URLs may rewrite.


1. Overview & scope

1.1 What this contract covers

The wire-level contract for publishing one or more ONIX 3.0/3.1 products to Bokbasen's metadata import service, including:

  • OAuth2 client-credentials authentication (token acquisition + caching).
  • The single-phase async ingestion POST (Onix Import V2).
  • Status polling (per-item + list) until terminal state (COMPLETED / FAILED).
  • Object Import for cover images and audio samples (separate endpoint, binary upload, synchronous 201).
  • Error-envelope shape and HTTP status mapping.
  • Rate-limit / retry / Retry-After semantics (best-known; one open question remains).

1.2 What this contract defers

Deferred toWhat
Phase 7 WI2Credential plumbing: encrypted storage in the plugin_settings table + env-var fallback; this doc only specifies the *header format* the credential lands in.
Phase 7 WI3The actual Req-based HTTP client module (function names, struct shapes, telemetry). This doc specifies the URLs, methods, headers, bodies, and error mapping; WI3 turns those into Elixir code.
Phase 7 WI4Oban worker that orchestrates stage → poll → terminal-state and writes back to the publish-job row. This doc specifies the state machine; WI4 implements the worker.
Phase 7 WI5–WI7Studio UI hooks, queue back-pressure, ops dashboard.
Phase 8 / Task #12Bokbasen → barkpark *acknowledgement loop* (Bokbasen pushing back catalogue-entry confirmations). The Phase 7 flow stops at "Bokbasen says COMPLETED"; downstream "did the record actually land in DDS / surface to retailers" is a future task.
Object ImportListed here for completeness because the contract is needed for the cover-image flow, but the WI4 worker is metadata-only — Object Import becomes its own WI in a later phase (it is not in Task #1's WI2–WI7 acceptance criteria).

1.3 ⚠️ Important contract finding — re-framing "two-phase claim"

The Phase 7 task brief refers to a two-phase claim lifecycle. This language does not match Bokbasen's actual public API. Bokbasen exposes a single-phase asynchronous ingestion model:

There is no separate `claim` / `confirm` step, and no abort endpoint. Once the synchronous POST returns 202 Accepted, the record is committed to the asynchronous queue; the only way to "abort" is to let the record fail and either resubmit or NotificationType=05-delete it. See §4 for how this maps onto the WI4 worker state machine.

The remaining "two-phase" semantics in the task brief should be read as:

  • Phase A (synchronous): stage POST returns 202 + status URL.
  • Phase B (asynchronous): poll the status URL until terminal state.

This is captured as [blocking] Q-A in §9 — Boss confirms whether that re-framing is the intended Phase 7 acceptance criterion before WI4 codes the state machine.


2. Auth

2.1 Decision

ItemPinned valueSource
MethodOAuth2 `client_credentials` flow[Authentication Service][^auth]
Token endpoint (PROD)POST https://auth.bokbasen.io/oauth/token[^auth]
Token endpoint (TEST/sandbox)POST https://auth.stage.bokbasen.io/oauth/token[^auth]
Audience (metadata import)https://api.bokbasen.io/metadata/[^auth] [^import]
Request body content-type (preferred)application/x-www-form-urlencoded (also accepts application/json, also accepts Basic-auth-encoded credentials)[^auth]
Required body paramsclient_id, client_secret, audience, grant_type=client_credentials[^auth]
ResponseJSON: {access_token, expires_in, token_type:"Bearer"}[^auth]
Authorization header on subsequent callsAuthorization: Bearer <access_token>[^auth] [^import]

2.2 Caching

Bokbasen actively documents caching:

WI3 client must cache the bearer token keyed by (audience, client_id) and refresh at exp - 60s. Token lifetime is variable but typically expires_in: 86400 (24 h) per the published JWT example.[^auth]

2.3 Credential plumbing (forward-reference WI2)

WI2 owns credential storage; this doc only fixes the shape of the secret and the env-var override names. Proposed:

Settingplugin_settings keyEnv overrideNotes
Client IDbokbasen.oauth.client_idBOKBASEN_CLIENT_IDPlain text
Client secretbokbasen.oauth.client_secretBOKBASEN_CLIENT_SECRETEncrypted at rest via Cloak (cloak_ecto already in api/mix.exs)
Audiencebokbasen.oauth.audienceBOKBASEN_AUDIENCEDefault https://api.bokbasen.io/metadata/; allows future audience switching
Environmentbokbasen.environmentBOKBASEN_ENVOne of prod / stage; selects token endpoint and base URL

3. Endpoints

All endpoints accessed at base host https://api.bokbasen.io (PROD). Sandbox base host is OPEN — see §9 Q-C.

NameMethodPathContent-Type (req.)Required headersBody shapeResponse shapeNotes
Token (auth)POSThttps://auth.bokbasen.io/oauth/tokenapplication/x-www-form-urlencoded *or* application/json(none)Form: client_id, client_secret, audience, grant_type=client_credentialsJSON {access_token,expires_in,token_type}Returns 200 OK / 401 / 403. [^auth]
Stage (V2 — preferred)POST/metadata/import/onix/v2application/xmlAuthorization: Bearer …Raw ONIX XML — accepts a <Product> element *or* a full <ONIXMessage> with one or more <Product> children. ONIX 3.0.x or 3.1.x. No multipart, no gzip in public docs.202 Accepted (no body) + Location: https://api.bokbasen.io/metadata/import/onix/v2/status/<uuid>. On 400 returns error XML (see §5).Audience must be https://api.bokbasen.io/metadata/. [^import]
Stage (V1 — legacy)POST/metadata/import/onix/v1application/xmlAuthorization: Bearer …Raw ONIX XML — single peeled `<Product>` element only, no <ONIXMessage> wrapper, no <Header>. ONIX 3.0.x.202 Accepted (no body) + Location: https://api.bokbasen.io/metadata/import/onix/v1/status/<uuid>.The barkpark Phase 6 export currently emits a full <ONIXMessage>, so V2 is the natural fit. [^import]
Status (poll one)GET/metadata/import/onix/v1/status/{uuid} *(see §3.1 on V2 path)*Authorization: Bearer …(none)XML <importItem> with <id>, <registered>, <state>, optional <errors>, <warnings>, <actionsCompleted>. State ∈ {UNPROCESSED, COMPLETED, FAILED}.200 OK / 400 / 401 / 403 / 500. [^import]
Status (list all)GET/metadata/import/onix/v1/status/allAuthorization: Bearer …(none)XML list of <importItem> summaries (<id>, <url>, <registered>, <state>).200 OK / 400 / 401 / 403 / 500. [^import]
Object import (cover/audio)POST/metadata/import/object/v1/{ean}/{type}image/jpeg | image/png | audio/mpeg (per {type})Authorization: Bearer …Binary file content. ≤ 10 MB.201 Created (no body) on success; XML on validation error.{type}productimage (jpeg/png) or audiosample (mp3). [^import]

3.1 Status path notes

The V2 stage endpoint *returns* a Location header pointing at /metadata/import/onix/v2/status/<uuid>[^import]:

HTTP/1.1 202 Accepted
Location: https://api.bokbasen.io/metadata/import/onix/v2/status/b64555a0-1d4b-472c-91e5-461a034a8fde

…but the public docs only spec the status response shape under the V1 path (/metadata/import/onix/v1/status/{uuid}). The V2-mounted status URL is presumed to return the same XML schema. This is [non-blocking] Q-D in §9. Action for WI3: always read the URL from the `Location` header verbatim, never construct a status URL by string-templating the version segment.

3.2 Idempotency

Bokbasen's public docs do not spec an Idempotency-Key header. Record-level idempotency is provided implicitly by ONIX <RecordReference> plus <NotificationType>:

  • A duplicate POST with the same <RecordReference> and updated content overwrites the prior record (semantics defined per Onix Block in [^import] §"Deletion of data").
  • <NotificationType>05 ("delete this record") is the explicit delete path — there is no separate DELETE endpoint.

WI4 worker idempotency strategy: deduplicate at the publish-job layer (one Oban job per (book_id, attempt)); rely on Bokbasen to overwrite a republished record by <RecordReference> if the worker retries after a network blip. This avoids needing a Bokbasen-side idempotency key. Tagged [non-blocking] Q-E in §9 for confirmation.


4. Lifecycle (re-framed two-phase)

4.1 State machine

                   ┌────────────────────────────────────────┐
                   │                                        │
                   │  WI4 Oban worker — per publish-job     │
                   │                                        │
                   └────────────────────────────────────────┘

   PENDING ──[render ONIX XML]──▶ READY_TO_STAGE
                                         │
                                         │ POST /metadata/import/onix/v2
                                         ▼
                          ┌──────────────────────────────┐
                          │ HTTP 202 Accepted            │
                          │ Location: …/status/<uuid>    │
                          └──────────────────────────────┘
                                         │
                                         │  persist <uuid> + status URL
                                         │  on the publish-job row
                                         ▼
                                    STAGED
                                         │
                                         │ schedule poll (initial delay 5s,
                                         │ exp. backoff capped at 60s; see §6)
                                         ▼
                                  ┌─────────────┐
                          GET ───▶│ POLL_STATE  │◀── reschedule if still
                                  └─────────────┘    UNPROCESSED
                                    │  │   │
        ┌───────────────────────────┘  │   └─────────────────────────┐
        │                              │                             │
        │ state=COMPLETED              │ state=FAILED                │ no terminal
        │                              │                             │ within budget
        ▼                              ▼                             ▼
  ┌───────────┐                  ┌──────────┐                ┌────────────────┐
  │ COMPLETED │                  │  FAILED  │                │ TIMEOUT        │
  │ + actions │                  │ + errors │                │ (operator      │
  │ Imported  │                  │ + warns  │                │  intervention) │
  └───────────┘                  └──────────┘                └────────────────┘
        │                              │                             │
        ▼                              ▼                             ▼
  publish-job:                   publish-job:                   publish-job:
  status=published               status=rejected                status=stuck
  bokbasen_id=<uuid>             bokbasen_errors=[…]            requires manual
                                                                 status check

4.2 Transitions in detail

TransitionTriggerSide effects
PENDING → READY_TO_STAGEWorker pulled job; renders ONIX XML via existing Barkpark.Plugins.OnixEdit.Export.export/2.Validate locally against vendored XSD before any network call. If local validation fails, transition straight to FAILED with error_source: "local_xsd" — never POST a known-invalid file.
READY_TO_STAGE → STAGEDPOST /metadata/import/onix/v2 returns 202.Persist (bokbasen_status_url, bokbasen_uuid, staged_at) on the publish-job row.
READY_TO_STAGE → FAILED (sync)Stage POST returns 400 (XML errors envelope) or 401.Persist parsed <error> entries. 401 triggers token refresh + one retry.
STAGED → POLL_STATEOban schedule fires.None.
POLL_STATE → STAGED (re-armed)GET status returns <state>UNPROCESSED</state>.Reschedule next poll per backoff schedule.
POLL_STATE → COMPLETEDGET status returns <state>COMPLETED</state>.Record actionsCompleted (which Onix Blocks were imported); broadcast a Phoenix PubSub event so Studio's publish dashboard can react in real time.
POLL_STATE → FAILED (async)GET status returns <state>FAILED</state>.Record <errors> and <warnings>. Do *not* auto-retry — Bokbasen explicitly says: *"A Product that is FAILED will not be acted upon by Bokbasen. It is the Senders responsibility to act upon FAILED Products and resubmit the Product with valid data."*[^import]
* → TIMEOUTTotal elapsed exceeds polling budget (proposed: 30 min).Job ends in stuck; operator runs the status-list endpoint to recover.

4.3 No abort, no claim

There is no abort/cancel endpoint on the import service. To "undo" a published record, the publisher submits a fresh ONIX message with <NotificationType>05</NotificationType>:

There is also no separate claim/confirm step. The 202 response *is* the commit; the async work is purely catalogue ingestion.

[pre-flight]: ./bokbasen-onix-pre-flight.md

4.4 Expiry / staleness

Bokbasen's public docs do not publish an expiry on the status/{uuid} URL. The status-list endpoint (/status/all)[^import] strongly implies status records are retained at least long enough for operators to see all currently-UNPROCESSED items, but the retention window is undocumented. Treated as [non-blocking] Q-F in §9; WI4 will use a 30-minute polling budget by default and surface stuck jobs to ops.


5. Error semantics

5.1 Status-code → error-class mapping

HTTPClassSource endpoint(s)WI4 handling
200 OKsuccessstatus endpointsParse <importItem>.
201 Createdsuccessobject importNo body.
202 Acceptedsuccess (async)stage V1, V2Read Location header; persist UUID; schedule poll.
400 Bad Requestvalidation error (terminal)stage V1, V2; object importParse error-XML envelope (§5.2); transition publish-job to rejected. Do not retry.
401 UnauthorizedauthallRefresh OAuth2 token (§2.2); retry once. If still 401, mark job auth_error and alert ops.
403 Forbiddenauth/scopestatus endpointsWrong audience, no permission for the resource. Mark job auth_error; do not retry.
413 Payload Too Largesize(export only — listed for completeness)[^export]Not expected on import; log + alert if seen.
500 Internal Server Errortransientstage V1, V2; status endpointsRetry with exponential backoff (§6).
502 / 503 / 504transient(inferred from typical Bokbasen Apache-Coyote frontend in Via: headers — see [^import] sample bodies)Retry with backoff.
429 Too Many Requestsrate-limitedOPEN — see §6.2 + §9 Q-GIf Retry-After present, honour it; else exponential backoff.

5.2 Sample error envelope (V1, real, quoted)

The Confluence "Import Service" page documents the V1 400 body schema verbatim[^import]:

<errors>
  <error ref="..." code="..." message="..."/>
  <error ref="..." code="..." message="..."/>
</errors>

Element / attribute meaning (verbatim):

The V2 documentation is currently marked *"documentation in progress.."* on the Confluence page; the V2 envelope shape is assumed identical to V1 (Bokbasen has not stated otherwise) but this is flagged as [non-blocking] Q-H in §9. WI3 will parse V1 and V2 errors with the same code path.

5.3 Sample status-detail body (real, quoted)

V1 status detail, COMPLETED with a warning, verbatim Confluence example[^import]:

<importItem>
  <id>a47ad10b-58cc-4372-a567-0e2342c3d479</id>
  <registered>20140907233000</registered>
  <state>COMPLETED</state>
  <warnings>
    <warning ref="xxx" message="Some warning text"/>
  </warnings>
  <actionsCompleted>
    <action type="onixBlockImported" value="1"/>
    <action type="onixBlockImported" value="4"/>
  </actionsCompleted>
</importItem>

The barkpark Phase 6 export currently emits Onix Blocks 1+2+3+4 (per [bokbasen-onix-pre-flight.md][pre-flight] §3.1 T6); a successful COMPLETED should therefore enumerate value="1"/"2"/"3"/"4", modulo the per-sender-role filtering rule documented in [^import] §"Onix Blocks". WI4 should *log* the imported blocks and surface them in the publish-job audit trail; missing blocks are not an error.

5.4 Sample status-list body (real, quoted, illustrative)

V1 status list[^import]:

<importItems>
  <importItem>
    <id>2b6eea56-f448-4210-a1d3-2dd49479567d</id>
    <url>https://api.bokbasen.io/metadata/import/onix/v1/status/2b6eea56-f448-4210-a1d3-2dd49479567d</url>
    <registered>20141002101349</registered>
    <state>UNPROCESSED</state>
  </importItem>
  <importItem>
    <id>b64555a0-1d4b-472c-91e5-461a034a8fde</id>
    <url>https://api.bokbasen.io/metadata/import/onix/v1/status/b64555a0-1d4b-472c-91e5-461a034a8fde</url>
    <registered>20141002142426</registered>
    <state>UNPROCESSED</state>
  </importItem>
</importItems>

<registered> is in yyyyMMddHHmmss (no timezone — assumed Europe/Oslo per Bokbasen's locale; flagged [non-blocking] Q-I in §9).


6. Rate limits & retry policy

6.1 What the public docs say

Bokbasen's public Confluence pages do not document a rate-limit policy for the metadata import endpoints. The most explicit operational guidance is on the *export* side, where 413 Try with a smaller pagesize is documented[^export] — that is a *payload-size* hint, not a request-rate limit, and only applies to export.

There is no documented:

  • X-RateLimit-Remaining / X-RateLimit-Reset header
  • Retry-After header (neither documented nor forbidden)
  • requests-per-minute ceiling
  • concurrent-uploads ceiling

This is captured as [blocking] Q-G in §9 — the WI4 Oban worker needs *some* rate to throttle to, even if Bokbasen's silent on the matter, so Boss must either (a) get a number from a Bokbasen partner contact, or (b) sign off on the proposed conservative defaults below.

6.2 Proposed conservative retry policy

Until §9 Q-G resolves, WI3 should pin Req's retry mode to :transient with the following parameters. All claims here are *engineering defaults*, not Bokbasen-quoted numbers — they are explicitly safe under-shoots.

# Pseudocode for WI3 — illustrative, not for paste
Req.new(
  retry: :transient,                # retry on network errors + 408/429/5xx
  max_retries: 5,                   # 5 attempts ≈ ~62s ceiling at 2^n
  retry_delay: fn attempt ->
    base_ms = min(60_000, :math.pow(2, attempt) * 1_000 |> trunc())
    jitter_ms = :rand.uniform(div(base_ms, 4))   # up to 25% jitter
    base_ms + jitter_ms
  end,
  retry_log_level: :warning
)

Backoff math (per attempt index, before jitter):

attemptdelay (s)
12
24
38
416
532
≥660 (cap)

Honour Retry-After if present (Req does this automatically in :transient mode for 429 responses; this is built into Req 0.5.x[^req-retry]). Cap total retry budget at 5 minutes per HTTP call — anything longer must surface to ops.

Status-poll cadence (orthogonal to HTTP retry — this is *intra-job* polling, not retry of a failed call):

poll #delay since previous
15 s after 202
25 s
310 s
420 s
5+60 s (cap)

Total polling budget: 30 minutes (matches * → TIMEOUT rule in §4.1).

6.3 Concurrency

WI4 Oban worker queue `:bokbasen_publish` with concurrency = 1 (one upload in flight per node) until §9 Q-G clears. This is deliberately conservative; raising it requires either a quoted Bokbasen ceiling or Boss sign-off.


7. HTTP client decision: Req

7.1 Decision

Pinned: `Req` ~> 0.5 (latest stable: 0.5.17).[^req-hex]

Already a direct dep in api/mix.exs:55:

{:req, "~> 0.5"},

WI1 does not add a Mix dep — the dep is already present and was introduced in Phase 6. WI2 / WI3 will use it as-is; if a newer 0.5.x patch lands during Phase 7, the ~> constraint picks it up at mix deps.update.

7.2 Why Req

NeedReqComment
Retry on transient errorsretry: :transient built-in[^req-retry]Honors Retry-After automatically; backoff via retry_delay lambda
application/xml bodyPass body: iodata, set Content-Type headerMatches Bokbasen V1/V2 stage payload exactly
OAuth2 bearer headerauth: {:bearer, token} shorthand[^req-readme]Plumbs token without manual header juggling
Streaming (object import 10 MB)Req.post!(url, body: file_stream) accepts iodata or StreamWorks for ≤10 MB cover uploads
Already in treeyesPhase 6 already shipped Req as a transitive dep; WI1 keeps the choice consistent

7.3 Alternatives considered

LibraryWhy not
Finch (raw)Lower-level — would force WI3 to hand-roll retry, redirect, decoding, content-type defaults. Req sits on top of Finch already, so we get Finch's pool benefits without re-implementing the ergonomics.
HTTPoisonMaintenance mode; no first-class Retry-After support; older hackney transport. No reason to introduce it alongside Req.
TeslaMiddleware-stack model is heavier than needed for two endpoints; adds a 2nd HTTP abstraction to a project that already has Req.
Mint (raw)Same argument as Finch: too low-level for an integration that needs retry + auth refresh out of the box.

7.4 Pinned version & dep line

# api/mix.exs (UNCHANGED — already present; WI1 makes no mix.exs edit):
# {:req, "~> 0.5"},
#
# Latest stable on hex: 0.5.17 (verified 2026-04-30 via hex.pm/api/packages/req)
# Phase 7 may bump to "~> 0.5.17" if a specific feature lands; see WI3.

8. Test mocking decision: Bypass

8.1 Decision

Pinned: `Bypass` ~> 2.1 (latest stable: 2.1.0).[^bypass-hex]

Bypass is not yet in api/mix.exs. WI3 (or WI3a, at WI3's discretion) will add the dep:

# api/mix.exs — proposed addition for WI3 (not WI1):
# {:bypass, "~> 2.1", only: :test},

WI1 does not add this dep — that is WI3's job.

8.2 Why Bypass

  • In-process Plug-based mock server. Tests can start_supervised a Bypass instance, register expect-style handlers per route, and drive the real Req client at it. No external service.
  • Compatible with Phoenix's existing test stack — bandit + plug are already in the tree.
  • Zero Mox required for the integration tests. The HTTP boundary is mocked at the *socket*, so the WI3 client module under test runs unmocked — gives us higher fidelity than function-level mocks.
  • Recorded golden-path fixtures can live alongside Bypass tests without any extra runtime: tests load XML fixtures from api/test/support/fixtures/bokbasen/ and the Bypass handler simply echoes them. ExVCR (cassette-based playback) is not needed because Bokbasen's responses are deterministic per <RecordReference> + <NotificationType> and Phase 6 already has golden ONIX fixtures.

8.3 Fixture layout (proposed for WI3)

api/test/support/fixtures/bokbasen/
├── auth/
│   ├── token-response.json                    # 200 OK token JSON
│   └── token-401.json                         # 401 Unauthorized
├── stage/
│   ├── 202-accepted.headers                   # response headers including Location
│   ├── 400-validation-errors.xml              # error envelope §5.2
│   └── 401-unauthorized.json
├── status/
│   ├── unprocessed.xml                        # state=UNPROCESSED
│   ├── completed-blocks-1234.xml              # state=COMPLETED, full §5.3 sample
│   ├── failed-with-errors.xml                 # state=FAILED
│   └── list.xml                               # multi-item list per §5.4
└── object/
    ├── 201-created.headers
    └── 400-payload-too-large.xml

8.4 Alternatives considered

LibraryWhy not
MoxFunction-level mocking — would require defining a behaviour for the WI3 client and substituting a stub in tests. That moves the mock seam *above* the HTTP layer, missing the very integration risks (header serialization, status-code parsing, error-XML decode) that WI1 needs to harden. Useful if/when WI3 grows internal collaborators worth mocking, but not for the primary HTTP contract test.
ExVCRCassette-based replay. Strong choice for *recording* real Bokbasen interactions, but: (1) no sandbox credentials available yet (§9 Q-C), (2) cassettes go stale silently, (3) we don't get error-injection control (Bypass lets each test return any HTTP status). Re-evaluate once sandbox creds land — at that point ExVCR could *complement* Bypass for one or two recorded golden-path tests.
Hand-rolled `Plug.Test` serverRe-implements Bypass. No reason to.
Tesla mock adapterWe're not using Tesla.

9. Open questions for Boss

Each entry tagged [blocking] (must resolve before WI4 codes) or [non-blocking] (WI4 ships with a documented assumption; resolution becomes a follow-up). All cross-link the relevant contract section.

9.1 Blocking

  • [blocking] Q-A — "two-phase claim" re-framing. The task brief uses the phrase "two-phase claim". Bokbasen's actual API is a single-phase async-poll model (§1.3). Confirm the WI4 worker should implement *Phase A = stage POST, Phase B = poll-until-terminal* and that there is no expectation of a separate Bokbasen-side "claim" step. Without this confirmation, WI4 cannot pin its Oban state machine.
  • [blocking] Q-C — Sandbox credential availability. §2.1 confirms a sandbox auth endpoint (https://auth.stage.bokbasen.io/oauth/token) exists. The corresponding metadata import sandbox base URL is *not* in public docs — https://api.stage.bokbasen.io/metadata/ is the obvious mirror but is not stated. We also need real client_id / client_secret for the sandbox. This blocks WI3 integration tests from running against a live mock host (Bypass covers unit tests; sandbox covers smoke tests).
  • [blocking] Q-G — Rate limits. Bokbasen publishes no documented rate limit for metadata imports (§6.1). Either get a number from Bokbasen partner contact or sign off on the conservative defaults in §6.2 (queue concurrency 1, max 5 retries, 60 s ceiling). WI4 needs *some* number to compile against.
  • [blocking] Q-J — Onix Block matrix for barkpark's sender role. Bokbasen scopes import permissions per sender role. A *Distributor* may write Blocks 1, 2, 4, 6; a *Publisher* gets a different (still partner-only) matrix.[^import] Phase 6 emits Blocks 1+2+3+4. We need to confirm what role barkpark's OAuth2 client lands under, so WI4 can correctly interpret actionsCompleted (silently-dropped blocks vs. genuine failures). Cross-references [bokbasen-onix-pre-flight.md][pre-flight] §3.1 T6 + §5 Q5.

9.2 Non-blocking

  • [non-blocking] Q-B — Env-var naming deviation. Task brief hints BOKBASEN_API_KEY / BOKBASEN_USERNAME / BOKBASEN_PASSWORD; this contract pins the OAuth2 vocabulary BOKBASEN_CLIENT_ID / BOKBASEN_CLIENT_SECRET. Boss confirms the rename is acceptable (it is the wire protocol's vocabulary). Default WI2 ships with the OAuth2 names.
  • [non-blocking] Q-D — V2 status path schema. The V2 stage endpoint returns a Location header at …/onix/v2/status/<uuid>, but only the V1 path's status-response schema is documented. WI3 will assume V1 == V2 schema and read the URL verbatim from the Location header (§3.1).
  • [non-blocking] Q-E — Idempotency-key support. No documented Idempotency-Key header (§3.2). WI4 dedupes at the publish-job layer and relies on <RecordReference>-based overwrite. Confirm this is acceptable end-state behaviour (no risk of double-publish events flowing to retailers).
  • [non-blocking] Q-F — Status URL retention. Bokbasen does not publish how long …/status/<uuid> URLs remain queryable (§4.4). WI4 uses a 30-min polling budget; long-lived monitoring assumes list-endpoint scrape.
  • [non-blocking] Q-H — V2 error envelope shape. Confluence labels V2 docs *"in progress.."*; V1 envelope is documented. WI3 assumes V1 == V2 and parses both with one code path; revisit if a real V2 body diverges.
  • [non-blocking] Q-I — Status `<registered>` timezone. Format is yyyyMMddHHmmss (no offset); assumed Europe/Oslo per Bokbasen locale. WI4 normalises to UTC on persist; flag if drift observed.
  • [non-blocking] Q-K — Object Import ownership. Object Import (cover image / audio sample) is in the contract for completeness but not in WI4's metadata-only scope. Confirm cover uploads land in a later WI/phase, not bolted onto WI4.
  • [non-blocking] Q-L — V1 vs V2 endpoint preference. Both V1 and V2 are live; V2 accepts the existing <ONIXMessage> wrapper barkpark already emits, V1 requires a peeled <Product> element. WI3 should default to V2; confirm Boss has no Bokbasen-side preference. (Cross-references [bokbasen-onix-pre-flight.md][pre-flight] §5 Q8.)

How to test

The Phase 7 deliverables ship with a tagged end-to-end integration test that drives the full publishing pipeline against a [Bypass][^bypass-hex] HTTP mock. The test is the executable, version-controlled counterpart to this contract — it confirms WI2/WI3/WI4 production code parses the exact response shapes documented above.

File layout

  • Test: api/test/barkpark/plugins/onixedit/bokbasen/e2e_test.exs
  • Redacted fixtures: api/test/fixtures/bokbasen/
  • oauth_token_response.json — OAuth2 client_credentials token reply (synthetic test_access_token_xyz, see §2.2).
  • stage_202_location.txt — 202 Location header value the worker parses to recover submission_id + poll_url (see §3.1).
  • poll_pending.xml / poll_accepted.xml / poll_rejected.xml XML poll-status fixtures with <state>UNPROCESSED</state> / <state>COMPLETED</state> / <state>FAILED</state> (see §3.2).
  • error_401.json / error_429.json / error_500.json — error envelopes the WI3 Errors module surfaces as AuthError, RateLimitError, HTTPError.

All fixture credentials are synthetic — test_* prefixes, api.example.com URLs, no real bearer tokens, no real submission IDs. The fixture filenames + shapes are CI-stable; treat them as the contract's executable annex.

Default mix test excludes the suite

The suite is tagged @moduletag :bokbasen_integration and excluded by default via api/test/test_helper.exs so CI's standard mix test invocation stays free of any external-shape fixtures and cannot leak real credentials. The format and mix-prod-compile gates run unaffected.

Run the integration suite locally

cd api
mix test --include bokbasen_integration

To run only the Bokbasen E2E test:

cd api
mix test --include bokbasen_integration \
  test/barkpark/plugins/onixedit/bokbasen/e2e_test.exs

Run against the real Bokbasen sandbox

Set the five env vars (see §2.2 *Authentication* for the published URLs) and clear the test-only stub:

export BOKBASEN_API_BASE="<api base from §2.2>"
export BOKBASEN_OAUTH_TOKEN_URL="<token endpoint from §2.2>"
export BOKBASEN_CLIENT_ID="<sandbox client id>"
export BOKBASEN_CLIENT_SECRET="<sandbox client secret>"
export BOKBASEN_CLIENT_ROLE="publisher"

cd api && mix run priv/scripts/publish_demo.exs   # if/when added

The integration test itself never reaches a real Bokbasen endpoint; it only exercises the local Bypass mock against the redacted fixtures.


Footnotes / sources

[^import]: Bokbasen Confluence — *Import Service*, page id 48955439. Retrieved via https://bokbasen.jira.com/wiki/rest/api/content/48955439?expand=body.storage on 2026-04-30. Human URL: https://bokbasen.jira.com/wiki/spaces/api/pages/48955439/Import+Service

[^auth]: Bokbasen Confluence — *Authentication Service*, page id 2994962433. Retrieved via REST API on 2026-04-30. Human URL: https://bokbasen.jira.com/wiki/spaces/api/pages/2994962433/Authentication+Service

[^export]: Bokbasen Confluence — *ONIX*, page id 67993632. Documents the export endpoint with the 413 Try with a smaller pagesize language. Retrieved 2026-04-30. Human URL: https://bokbasen.jira.com/wiki/spaces/api/pages/67993632/ONIX

[^php-auth]: Bokbasen GitHub — *php-sdk-auth*, file src/Auth/Login.php. Source verifies the legacy TGT auth flow (POST https://login.boknett.no/v1/tickets, header Boknett-TGT) that the modern OAuth2 endpoint replaces. Retrieved 2026-04-30 via https://raw.githubusercontent.com/Bokbasen/php-sdk-auth/master/src/Auth/Login.php

[^req-hex]: Hex.pm package metadata for reqlatest_stable_version: 0.5.17, total downloads ~12 M. Verified 2026-04-30 via https://hex.pm/api/packages/req

[^req-readme]: Req README on hexdocs — https://hexdocs.pm/req/readme.html retrieved 2026-04-30. Documents auth: {:bearer, token} shorthand and :transient retry mode.

[^req-retry]: Req docs — Req :retry option :transient mode retries on 408/429/500/502/503/504 and honors Retry-After. https://hexdocs.pm/req/Req.Steps.html#retry/1 retrieved 2026-04-30.

[^bypass-hex]: Hex.pm package metadata for bypasslatest_stable_version: 2.1.0. Verified 2026-04-30 via https://hex.pm/api/packages/bypass