Docs menu and search Integrations guide

Integrations guide

This guide documents the full Merchant API — the same product-setup, admin-invite and lock capabilities Periode uses internally, scoped to a single merchant by your API key. With it you can build your own integration (for example your own MCP server) that manages your products, invites admins and configures locks programmatically.


New here? The Getting started guide covers the server URL, your x-api-key, a connectivity test, and the shared conventions (minor units, decimal hours, whole-field replace, error semantics) plus a ready-to-import Postman collection. This page assumes that setup and documents each endpoint; all paths are relative to the base URL and require the x-api-key header.


Products

Periode has four product types. Each has a URL slug used in the paths below:

  • Booking — slug booking (BookingManifest)
  • Membership / Subscription — slug subscription (Manifest)
  • Gift card — slug gift-card (GiftCardManifest)
  • Punch pass — slug punch-pass (PunchPassManifest)

List all products

GET /merchants/:id/allProducts

Returns a simplified entry per product across every type (booking products, punch passes, gift cards, subscriptions and waivers):

[
  {
    "id": "abc123",
    "name": "Climbing course",
    "type": "booking_manifest",
    "apiSlug": "booking",
    "archived": false
  },
  {
    "id": "def456",
    "name": "Monthly membership",
    "type": "subscription",
    "apiSlug": "subscription",
    "archived": true
  }
]
  • type is Periode’s internal product type (booking_manifest, punch_pass, gift_card, subscription, waiver).
  • apiSlug is the value to put in the URL of the endpoints below (booking, punch-pass, gift-card, subscription, waiver). Use this one when building a path — type and apiSlug differ for three of the five types.
  • archived marks retired products. Products are never hard-deleted, so an archived product stays in this list forever.

Archived products are included by default. Pass ?includeArchived=false to omit them:

GET /merchants/:id/allProducts?includeArchived=false

Use the id + apiSlug with the next endpoint to fetch the full document.

Get a full product

GET /merchants/:id/products/:type/:productId

:type is one of booking, subscription, gift-card, punch-pass or waiver. Returns the complete product document. A product id that doesn’t exist — or belongs to another merchant — is rejected as not found.

Create a product

POST /merchants/:id/products/:type

:type is booking, subscription, gift-card or punch-pass. The body is the product fields for that type (see below). Server-side defaults are applied first, then your fields are merged on top. Returns the created product’s id:

{ "id": "abc123" }

Update a product

PUT /merchants/:id/products/:type/:productId

Loads the current product, merges your fields on top (whole-field replace) and saves. Returns { "id": "..." }.

Two things to know before you script this:

  • There is no PATCH. Every key you send replaces that field entirely — to change one element of an array, send the whole new array.
  • What omitting a field does depends on the product type. Booking and punch-pass updates validate against a partial schema, so send only the fields you want to change and the rest keep their stored values. Gift cards behave the same way. Subscriptions are the exception: their update submits the whole form, so omitted optional fields are cleared — GET the product first, change the one value, and send the whole document back.
  • Strip updatedAt (and createdAt) before sending to booking and punch-pass. Those schemas are strict, so leaving them on a document you just fetched fails the call with unrecognized_keys. The subscription and gift-card schemas are not strict and simply ignore unknown keys.

Different prices at different times

You do not need one product per price. Each tier in discounts can carry its own weekly schedule, and the booking’s start time decides which price applies. The shape mirrors the product schedule — exactly 7 entries, Monday-first — but each slot carries a price in minor units:

{
  "name": "Standard",
  "price": 20000,
  "id": 1,
  "canLiveAlone": true,
  "schedule": [{ "startTimes": [{ "hour": 12, "minute": 0, "length": 4, "price": 10000 }] }]
}

A booking starting inside that window uses the slot’s price; start times outside every window, and days with an empty startTimes, fall back to the tier’s own price. So a weekday-afternoon rate is one product with a weekday/weekend schedule on the tier, not two products with different weekday masks. datesWithoutSchedule (["YYYY-MM-DD"]) disables the tier schedule on specific dates.

Publishing slots immediately (publishHours)

On POST /merchants/:id/products/booking and PUT /merchants/:id/products/booking/:productId you may include a publishHours object alongside the product fields:

{ "publishHours": { "days": 30 } }

It is a control field, not a product field — it is never stored on the product. It publishes bookable date-slots from now through days ahead immediately, rebuilding slots that were already published. Slots with active bookings (and frozen slots) are preserved as they are.

days may be at most 400. Use it whenever you change a field that changes the shape of the slot grid — length, tickSize, schedule, closedDates. The nightly job will not do this for you: it only fills in new days at the far end of the generateDaysAhead horizon and never rewrites a day that already has published slots. Change length without publishHours and the already-published days keep their old session lengths indefinitely.

Booking products can also be managed with the older dedicated POST /merchants/:id/createBookingManifest and PUT /merchants/:id/updateBookingManifest endpoints documented in the Booking API guide. They write the same product document but reject discounts and lockSystems, so prefer the /products/booking routes above for anything beyond the basics.


Membership / Subscription fields (subscription)

Required on create: interval, intervalCount, price, productName, productDescription, scopes, maxNumber, chooseNumber.

  • interval ("WEEK" | "MONTH" | "YEAR"): uppercase. "DAY" is not supported.
  • intervalCount (number): charge every N intervals.
  • price (number): minor units.
  • firstChargePrice (number): amount charged on the first period (campaign price).
  • firstChargePriceCampaignDurationDays (number): how long the first-charge price applies.
  • mva (string): VAT as a percentage string, e.g. "25%".
  • productName (string).
  • productDescription (string).
  • scopes (array): any of email, phonenumber, address, birthdate, name.
  • maxNumber (number | null): max quantity per subscription, or null.
  • chooseNumber (boolean | null): whether the buyer picks a quantity.
  • supportedPaymentMethods (array): use ["ADYEN"]. VIPPS here is the standalone Vipps track — the one case where it is still needed is Vipps recurring, which Adyen does not cover. See Payment types in Getting started.
  • commitmentMonths (number): binding period.
  • noticePeriodIntervalCount (number): notice period before cancellation.
  • connectedPunchPassManifest (object): grants punches on each charge — see Punch passes. { numberOfPunches, punchPassManifestId, refill: { type: "topUp" } }.
  • extraFields, radioButtonGroup, transitionRules, manifestUpsells: advanced configuration; see the product form in the Backoffice.

On update, the subscription endpoint mirrors the merchant form and submits the whole product: several optional fields (commitmentMonths, noticePeriodIntervalCount, customInputs, manifestUpsells, transitionRules, firstChargePrice, mailTemplateId, nickname, radioButtonGroup, …) are cleared when omitted. Send their full intended state, not just a delta.


Gift card fields (gift-card)

Required on create: name, description, paymentType, and buyerScopes including email.

  • name (string).
  • description (string).
  • paymentType (array): use ["ADYEN"] — Adyen’s checkout already includes Vipps when it is enabled under Settings > Payment methods. See Payment types in Getting started.
  • priceChoices (array of { price }): preset amounts in minor units, e.g. [{ "price": 50000 }].
  • freeAmount (boolean): allow the buyer to enter any amount.
  • maxAmount (number | null): max for a free-amount gift card (minor units).
  • buyerScopes (array): any of email, phonenumber, address, name. Must include email.
  • canBeGift (boolean): allow buying for a recipient.
  • giftRecipientScopes (array): recipient info to collect when canBeGift.
  • termsAndConditionsUrl (string).
  • termsAndConditionsUserMustAccept (boolean).
  • imageUrl, imageUrlEmail, imageUrlBackground (string): artwork URLs.
  • redirectLink (string): post-purchase redirect.

Punch pass fields (punch-pass)

  • name (string).
  • description (string).
  • paymentType (array): use ["ADYEN"]. See Payment types in Getting started.
  • bookingProducts (array of { bookingId, cost }): which booking products the pass pays for, and how many punches each booking costs (use cost: 1 for one punch per session).
  • choices (array of { numberOfPunches, amount, expireDays? }): bundles a customer can buy directly. amount is in minor units. Leave [] for a pass only granted via a membership.
  • vat (number): fraction 0–1.
  • expireDays (number | null): punches expire this many days after being granted; null = never.
  • maxBookingUsages (number | null).
  • personal (boolean).
  • scaleUsageOnLength (boolean).
  • schedule (array): time-of-day punch costs.

“X sessions per month” membership — create the punch pass with bookingProducts: [{ bookingId, cost: 1 }] (and choices: [] if it is only granted via a membership), then set connectedPunchPassManifest on the subscription product:

{
  "connectedPunchPassManifest": {
    "numberOfPunches": 4,
    "punchPassManifestId": "<punch-pass-id>",
    "refill": { "type": "topUp" }
  }
}

topUp resets the balance each cycle; { "type": "rollOver", "maxRollOver": 8 } carries unused punches over.


Booking fields (booking)

Booking products have the richest configuration. The essentials:

  • enabled (boolean): set true to make the product live (default false).
  • name (string): 3–99 characters.
  • description (string).
  • type (string): event, recurring, ticket, open-event, cross-day, many-days, live, period or resources. The legacy createBookingManifest/updateBookingManifest endpoints accept only the first four.
  • price (number): minor units (the most-expensive tier).
  • vat (number): fraction 0–1.
  • capacity (number): seats per slot.
  • length (number): session length in decimal hours.
  • paymentType (array): use ["ADYEN"] — Adyen’s checkout already includes Vipps when it is enabled under Settings > Payment methods. See Payment types in Getting started.
  • schedule (array): exactly 7 entries (validated), Monday-first — index 0 = Monday … index 6 = Sunday. Each entry is { startTimes: [...] }, and each start time is a block { hour, minute, length } (length in decimal hours) covering a whole opening window — not one entry per session. Omit it to keep the default (7 empty days).
  • discounts (array): every customer-facing price tier (see Pricing & memberships below).
  • generateDaysAhead (number): how many days of slots to publish ahead.
  • minDays / maxDays (number): shortest and longest stay on cross-day and many-days products. They bound what the customer can pick, and what an autoPay-style days link parameter may ask for (see Link parameters). Defaults when unset: 1 to 7 nights for cross-day, 1 to 14 days for many-days.

These are the essentials; see the Booking API guide for the booking-product endpoints, more fields, and a schedule example.

Pricing & memberships

The top-level price is the standard (most-expensive) tier. Every customer-facing tier — including the standard one that equals price — must also appear in the discounts array; keep all tiers there (the legacy memberships field is not used for this). A tier looks like { "name": "Standard", "price": 20000, "id": 1, "canLiveAlone": true } — each needs a unique numeric id.

To apply a membership’s price to a booking product, add a tier whose requiredMembershipId is the subscription’s manifest id (usually with max: 1):

{
  "name": "Member",
  "price": 0,
  "id": 2,
  "canLiveAlone": true,
  "requiredMembershipId": ["<subscriptionManifestId>"],
  "max": 1
}

So a booking product a membership applies to keeps every tier in discounts: the standard tier plus one member tier per membership. (For “X sessions per month” memberships that must enforce a session count, use a connected punch pass instead of a 0-price member tier — a 0-price tier would grant unlimited free bookings.)

Schedule & publishing

schedule defines the weekly opening plan (7 entries, Monday-first). Bookable date-slots are generated from it for the next generateDaysAhead days. Those slots are published either by the nightly job when publishNightly and enabled are both true, or immediately by passing publishHours: { days: N } (see above). Note that enabled defaults to false on a newly created product, so a product created over the API gets no nightly slots until you enable it. closedDates (["YYYY-MM-DD"]) suppress generation on specific dates.


Admin invites

Invite a user to your merchant with one or more roles.

POST /merchants/:id/invites

Body

  • email (string, required): the invitee’s email.
  • roles (string array, required): any of admin, restrictedAdmin, bookingMaster, restrictedBooking.
  • sendEmail (boolean, optional, default false): when true, Periode emails the invitation. When false, no email is sent and you deliver the returned URL yourself.
  • userAccess (array, optional): tag-scoped permission entries.

Response

{
  "invitationId": "inv_abc123",
  "inviteUrl": "https://merchant.periode.no/invitation/confirm/inv_abc123"
}

The inviteUrl never expires — it is valid until the invite is accepted or deleted. The invitee must sign in with the invited email to accept.


Locks

A working door lock is three pieces that must be connected. Setting one up without the others does nothing — a lock system only fires once a booking product points at it.

  1. Lock secret (required first) — the API key for your lock provider (Inlet, Lokalkontoret), stored by name. Provider locks cannot work without it. Only the plain pin types (STATICPIN, WEEKLYSTATICPIN, EXTERNALPIN) need no secret.
  2. Lock in the integration — a LockSystem describing one physical lock: its type (how codes are issued) and, for provider types, the apiKeyId (the lock-secret name from step 1) plus the provider’s lockId.
  3. Selected by a booking product — the lock system does nothing until a booking product selects it, via the product’s lockSystems array. This is the step that activates it.

How it works end to end

When a customer books a product that has a connected lock system, Periode issues the access code for that booking automatically — via the provider for Inlet / Lokalkontoret, or the shared/weekly/per-booking code for the pin types. The code is valid for the booking’s time window, optionally widened by startHoursBefore and endHoursAfter on the connection.

Connecting a lock — step by step

1. Add the provider secret (skip for plain pin types) — POST /lockSecrets (below). Returns the name you reference next as apiKeyId.

2. Create the lock system — POST /lockSystems (below). For provider types set apiKeyId to the secret name from step 1. Returns the lockSystemId.

3. Attach it to a booking product — set the product’s lockSystems array via POST/PUT /products/booking (or the dedicated createBookingManifest endpoint). Each entry is a LockSystemKey:

"lockSystems": [
  {
    "id": "<unique-key-id>",
    "lockSystemId": "<lockSystemId from step 2>",
    "type": "PINCODE",
    "startHoursBefore": 0,
    "endHoursAfter": 0
  }
]
  • lockSystemId — the id returned in step 2.
  • type — the code mechanism, matching the lock system: PINCODE, AUTOMATIC, AUTOMATICPIN, AUTOMATICREDIRECT, AUTOMATICCYCLE, STATICPIN, WEEKLYSTATICPIN, LOKALKONTORETPIN, EXTERNALPIN, LOCKER.
  • startHoursBefore / endHoursAfter — hours to widen the code’s validity before the booking starts / after it ends (0 = exactly the booking window).

For the two pin types you supply the code yourself (documented in the Booking API guide): POST /merchants/:id/updatePincode/:manifestId sets the shared static pin for a STATICPIN product, and POST /merchants/:id/bookings/:bookingId/pincode sets a per-booking code for an EXTERNALPIN product.

Create a lock system

POST /merchants/:id/lockSystems

Body

  • name (string, required): a label for the lock system.
  • lockSystem (object, required): a discriminated union on type:
    • { "type": "STATICPIN", "pinCode": "1234" }
    • { "type": "WEEKLYSTATICPIN", "pinCode": ["1111", "2222", ...] }
    • { "type": "EXTERNALPIN" }
    • Inlet: { "type": "PINCODE", "apiKeyId": "...", "lockId": "...", "openTime": 5, "length": 10, "prefix": "", "postfix": "" }
    • Inlet redirect: { "type": "REDIRECT", "apiKeyId": "...", "lockId": "...", "openTime": 5, "length": 10 }
    • Inlet automatic: { "type": "AUTOMATIC" | "AUTOMATICPIN" | "AUTOMATICREDIRECT", "apiKeyId": "...", "lockId": "..." }
    • Inlet cycle: { "type": "AUTOMATICCYCLE", "apiKeyId": "...", "lockIds": ["..."] }
    • Lokalkontoret: { "type": "LOKALKONTORETPIN", "apiKeyId": "...", "objectToken": "..." }

Required fields per type — a missing one is the usual cause of a 400 here:

  • STATICPIN: pinCode (string).
  • WEEKLYSTATICPIN: pinCode (string array, one per weekday).
  • PINCODE (Inlet): apiKeyId, lockId, openTime, length, prefix, postfix.
  • REDIRECT (Inlet): apiKeyId, lockId, openTime, length.
  • AUTOMATIC, AUTOMATICPIN, AUTOMATICREDIRECT: apiKeyId, lockId.
  • AUTOMATICCYCLE: apiKeyId, lockIds (an array — not lockId).
  • LOKALKONTORETPIN: apiKeyId, objectToken (no lockId).
  • EXTERNALPIN, LOCKER, NOTSET: nothing extra.

Note that PINCODE is the Inlet provider type and needs all six fields; STATICPIN is the plain shared-code type and needs only pinCode. openTime and length are numbers, prefix and postfix strings (send "" if unused).

apiKeyId refers to a lock secret name (see below). It is not checked when the lock system is created — a name that doesn’t match a stored secret is accepted here and fails later when the lock scheduler runs, so make sure POST /lockSecrets has run first.

Response — { "id": "<lockSystemId>" }.

List lock systems

GET /merchants/:id/lockSystems

Returns your merchant’s lock systems.

Add a lock secret

Lock providers (Inlet, Lokalkontoret) authenticate with an API key stored as a named secret. Adding a secret is write-only — the key value can never be read back through the API.

POST /merchants/:id/lockSecrets

Body

  • provider (string, required): inlet or lokalkontoret.
  • name (string, required): the secret name (referenced as apiKeyId on a lock system). Names are unique within your merchant across providers — and they share a single namespace with the GA4/Meta tracking credentials below — so reusing an existing name overwrites whatever is stored under it. Keep lock and tracking secret names distinct.
  • key (string, required): the provider API key.

Response — { "provider": "inlet", "name": "..." } (the key is never echoed).

List lock secrets

GET /merchants/:id/lockSecrets

Returns the secret names grouped by provider — never the key values:

{ "inlet": ["main-door"], "lokalkontoret": [] }

Tracking credentials (GA4 / Meta)

Periode reports completed bookings as server-to-server conversions to Google Analytics 4 and the Meta Conversions API. See the GA4 and Meta tracking guide for the full picture; this section covers only how to wire it up over the API.

Setup is two steps, and both can be scripted:

  1. Register the customer’s credentials once — POST /integrationSecrets.
  2. Point each booking product at them by name — set ga4Integration and/or metaIntegration on the product via POST/PUT /products/booking. The value is the name you registered in step 1.

Tracking is enabled per booking product, not per merchant, so step 2 is needed for every product that should report conversions. Subscriptions, gift cards and punch passes do not fire conversions.

Add a tracking credential

Write-only, exactly like lock secrets — the key value can never be read back.

POST /merchants/:id/integrationSecrets

Body — a discriminated union on provider:

  • GA4: { "provider": "ga4", "name": "Main website", "key": "<API Secret>", "measurementId": "G-XXXXXXXXXX", "debugMode": false }

  • Meta: { "provider": "meta", "name": "Facebook ads", "key": "<CAPI access token>", "pixelId": "123456789", "testCode": "TEST12345" }

  • name (string, required): the label you reference from a booking product’s ga4Integration / metaIntegration. Reusing a name overwrites that credential.

  • key (string, required): the GA4 API Secret, or the Meta Conversions API access token.

  • measurementId (GA4, required): must look like G-XXXXXXXXXX.

  • pixelId (Meta, required): digits only.

  • debugMode (GA4, optional) / testCode (Meta, optional): for validating events against the providers’ debug endpoints.

Response — { "provider": "ga4", "name": "..." } (the key is never echoed).

List tracking credentials

GET /merchants/:id/integrationSecrets

Returns the credential names grouped by provider — never the key values:

{ "ga4": ["Main website"], "meta": ["Facebook ads"] }

Prefilling merchant sign-up

The self-service Create Merchant page can be prefilled from a URL so you can send a customer a ready-to-confirm link. Add any of these query parameters:

name, orgno, email, phone, website, currency, timezone.

https://merchant.periode.no/create-merchant?name=Acme%20AS&orgno=999888777&currency=NOK&timezone=Europe/Oslo

Every field remains editable — prefilled values are only defaults. A help button on the page documents the same scheme.