# Create members with memberships

`POST /api/memberships/add-members-with-memberships`

- **Success:** `201 Created`
- **Products:** Operator (`FOH`), Consumer (`CONSUMER`) (any one of these products, not all)
- **Features:** Online Joining (`ONLINEJOIN`), Front of House Generic Access (`FOHGENERIC`) (any one of these features, not all)

HTML version: [Create members with memberships](https://support.gladstonesoftware.cloud/reference/joining/create-members-with-memberships)

## Overview

Creates a primary member, optionally a set of linked members alongside them, and the memberships
they are buying — all from one signed pricing quote. It is the single call behind a self-service
join: everything from "here are my details" to "here are the invoices to pay" happens here.

Get a pricing quote first. The quote is signed and must come back **unaltered**; change anything
inside `pricingQuote` and the call fails with `The pricing quote is invalid.` It is valid for
**one hour** from its `timeStamp` and may not be more than **ten minutes** ahead of the server's
UTC clock. `siteId` at the top level must equal `pricingQuote.siteId`.

**What this call does and does not finish.** It is presented as one operation, but it runs as a
sequence of steps: create the members, link them, write user fields, bank details, contact details,
addresses, marketing consents and signed agreements, then create the memberships and confirm the
members. If any step fails, the members created so far are **deleted** and you get the failing
step's status back. That rollback can itself fail — when it does you
are left with members that exist but have no memberships, and they must be cleaned up by hand.

What the rollback never covers is the money. A `201` means members, memberships and **unpaid**
sales invoices exist. Nothing is paid, and no membership is active, until you settle
`membersCreated[].salesInvoiceIds` with
[Pay for sales invoices](../payments/pays-for-sales-invoices-using-a-specified-payment-provider-and-can-handle-paymen.md)
— one call per member, since that endpoint is scoped to a single `memberId`. Stop between the two
and you have real members holding real memberships with an outstanding balance. The call is not
idempotent: retrying creates a second set of everything, or fails on duplicate-member validation.

**Order keys** are how the quote lines are matched to the people in this request. Each member
carries an `orderKey` that must correspond to a `pricingQuote.memberships[].orderKey`, so the
platform knows who is buying what. A member with `orderKey` `0` or absent is created but given no
membership.

## Endpoint

`POST {base_url}/api/memberships/add-members-with-memberships`

`{base_url}` is the environment URL issued to your integration. See [Authenticating with Gladstone APIs](../../guides/authenticating-with-gladstone-apis.md).

## Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `siteId` | string | Yes | The site the members are being created at and the memberships sold at. Maximum 15 characters and must not be empty. It must be identical to `pricingQuote.siteId` — a mismatch is the first thing checked and fails with `The site ID of the member must match the site ID in the membership pricing quote.` It also overwrites every `profile.siteId` in the request. |
| `primaryMember` | object | Yes | PrimaryMember cannot be empty. |
| `primaryMember.orderKey` | integer (int32) | No | Which line of the pricing quote this primary member is buying, matching a `pricingQuote.memberships[].orderKey`. At least one member in the request must carry a non-zero order key, or the call fails with `At least one orderKey for the Primary or Linked members must be specified.` Order keys must be unique across the primary and all linked members (`The orderKeys for the Primary and Linked members cannot be repeated.`). Omitting it, or sending `0`, creates the primary member without any membership — useful for a child who is only being added to the account. Unless `hasGuardian` puts the request into guardian mode, every non-zero key must exist in the quote. |
| `primaryMember.profile` | object | Yes | Profile cannot be empty. |
| `primaryMember.profile.titleId` | string | No | Title ID cannot be more than 15 characters. |
| `primaryMember.profile.firstName` | string | Yes | First name cannot be more than 25 characters. |
| `primaryMember.profile.email` | string | No | The primary member's email address. **Effectively required**: an empty value or one over 255 characters is rejected with `The primary member's email must be supplied and be less than 256 characters.`, despite the field being optional in the schema. It is also used for the duplicate-member check, so an address already on file can fail the whole request. |
| `primaryMember.profile.lastName` | string | Yes | Last name cannot be more than 25 characters. |
| `primaryMember.profile.dateOfBirth` | string (date-time) | No | Date of Birth cannot be in the future. |
| `primaryMember.profile.siteId` | string | No | **Ignored.** Whatever you send here is overwritten with the top-level `siteId` before the member is created, so there is no way to place a member at a different site from the one the quote was priced for. |
| `primaryMember.profile.pendingConfirmation` | boolean | No | Marks the member as awaiting confirmation at the moment they are created. It does not survive a successful call: once the memberships are created this endpoint confirms every member it made, so a `201` always leaves confirmed members. It matters only in that a member left behind by a failed rollback may still be in the pending state. |
| `primaryMember.profile.memberLanguage` | string | No | **Ignored.** Overwritten with the top-level `memberLanguage` before the member is created. Set the language once at the top level; there is no way to give members in the same request different languages. |
| `primaryMember.addressDetails` | object | No | The primary member's addresses, up to two. The whole object is optional — omit it and no address is recorded and no address validation runs at all. But once you supply it, exactly one of `address1` and `address2` must be marked `isPrimary`, or you get `The primary member needs to have exactly one primary contact address.` |
| `primaryMember.addressDetails.address1` | object | No | The primary member's first address. Sending it entirely empty while marking it primary is rejected — `The primary contact address of the primary member cannot be empty.` Individual fields are limited to 75 characters, except `postalCode` and `country` at 25. |
| `primaryMember.addressDetails.address1.addressLine1` | string | Yes | AddressLine1 cannot be more than 75 characters. |
| `primaryMember.addressDetails.address1.addressLine2` | string | No | AddressLine2 cannot be more than 75 characters. |
| `primaryMember.addressDetails.address1.addressLine3` | string | No | AddressLine3 cannot be more than 75 characters. |
| `primaryMember.addressDetails.address1.townCity` | string | Yes | TownCity cannot be more than 75 characters. |
| `primaryMember.addressDetails.address1.region` | string | No | Region cannot be more than 75 characters. |
| `primaryMember.addressDetails.address1.postalCode` | string | Yes | PostalCode cannot be more than 25 characters. |
| `primaryMember.addressDetails.address1.country` | string | No | Country cannot be more than 25 characters. |
| `primaryMember.addressDetails.address1.isPrimary` | boolean | No | Marks this as the primary member's main address. Exactly one address must carry `true` whenever `addressDetails` is supplied; both `true` or neither `true` fails validation. Defaults to `false` when omitted, so a single address object with this left out will fail. |
| `primaryMember.addressDetails.address2` | object | No | A second address for the primary member, such as a term-time or correspondence address. Optional. Subject to the same emptiness and length rules as `address1`, and it counts towards the exactly-one-primary rule. |
| `primaryMember.addressDetails.address2.addressLine1` | string | Yes | AddressLine1 cannot be more than 75 characters. |
| `primaryMember.addressDetails.address2.addressLine2` | string | No | AddressLine2 cannot be more than 75 characters. |
| `primaryMember.addressDetails.address2.addressLine3` | string | No | AddressLine3 cannot be more than 75 characters. |
| `primaryMember.addressDetails.address2.townCity` | string | Yes | TownCity cannot be more than 75 characters. |
| `primaryMember.addressDetails.address2.region` | string | No | Region cannot be more than 75 characters. |
| `primaryMember.addressDetails.address2.postalCode` | string | Yes | PostalCode cannot be more than 25 characters. |
| `primaryMember.addressDetails.address2.country` | string | No | Country cannot be more than 25 characters. |
| `primaryMember.addressDetails.address2.isPrimary` | boolean | No | Marks this as the primary member's main address instead of `address1`. Only one of the two may be `true`. |
| `primaryMember.contactDetails` | object | No | The primary member's phone numbers. The whole object is optional — omit it and no numbers are recorded and the primary-number rule is not applied. Supply it and exactly one of `telephone1`, `telephone2` and `mobile` must be marked `isPrimary`: zero gives `The primary member needs to have one primary contact phone number.` and more than one gives `The primary member cannot have multiple primary contact phone numbers.` Note the rule counts flags, not numbers: an entry flagged primary with a blank `number` satisfies validation but records nothing. |
| `primaryMember.contactDetails.telephone1` | object | No | A landline or general contact number for the primary member. |
| `primaryMember.contactDetails.telephone1.number` | string | No | The number itself, as free text. Only entries with a non-empty number are saved, so a blank number here means nothing is recorded even though the entry may still count towards the primary-flag rule. |
| `primaryMember.contactDetails.telephone1.isPrimary` | boolean | No | Marks this as the primary member's main contact number. Exactly one of the three numbers must carry `true` whenever `contactDetails` is supplied. Defaults to `false`. |
| `primaryMember.contactDetails.telephone2` | object | No | A second landline or alternative contact number for the primary member. |
| `primaryMember.contactDetails.telephone2.number` | string | No | The number itself, as free text. A blank number records nothing. |
| `primaryMember.contactDetails.telephone2.isPrimary` | boolean | No | Marks this as the primary member's main contact number. Only one number across the three may be `true`. |
| `primaryMember.contactDetails.mobile` | object | No | A mobile number for the primary member. |
| `primaryMember.contactDetails.mobile.number` | string | No | The number itself, as free text. A blank number records nothing. |
| `primaryMember.contactDetails.mobile.isPrimary` | boolean | No | Marks the mobile as the primary member's main contact number. Only one number across the three may be `true`. |
| `primaryMember.bankDetails` | object | No | Bank details for collecting direct debit payments. Whether it is required is decided from the quote, not from a flag you set: if any membership's recurring schedule names a BACS direct debit payment method other than the operator's default recurring-card method, omitting this fails with `Membership types "{ids}" require the primary member to provide bank details`. Only the primary member's bank details are ever taken — linked members cannot supply their own here — and they are only saved when the quote actually needs them, so sending them for a card-only purchase is silently ignored. `accountNumber` must be exactly 8 digits and `sortCode` exactly 6, both as strings; a leading zero dropped by a JSON number type is a common cause of rejection. |
| `primaryMember.bankDetails.accountName` | string | Yes | Maximum 50 characters. |
| `primaryMember.bankDetails.accountNumber` | string | Yes | Value must be a string of 8 digits. |
| `primaryMember.bankDetails.sortCode` | string | Yes | Value must be a string of 6 digits. |
| `primaryMember.userFields` | array of object | No | Values for the operator's custom member fields. Optional; omit or send an empty array when there are none. Each entry must carry both an id and a value — there is no way to send a field with a deliberately blank value through this endpoint. |
| `primaryMember.userFields[].userFieldId` | integer (int32) | Yes | User field ID cannot be empty. |
| `primaryMember.userFields[].value` | string | Yes | User field value cannot be empty. |
| `primaryMember.optInMarketing` | boolean | No | A single switch that opts the primary member in to **every** marketing campaign and **every** communication channel the operator has configured, at `siteId`. There is no per-channel or per-campaign control here: it is all or nothing. Defaults to `false`, which records no consent at all rather than an explicit refusal. |
| `primaryMember.signedAgreements` | object | No | Which agreements the primary member has accepted, and the object that makes the call succeed or fail: `privacyPolicy` and `termsAndConditions` must both be `true`. Omitting the object entirely is treated the same as refusing, and fails with `The primary member needs to agree to the privacy policy and terms and conditions.` The date of acceptance is recorded by the platform at the time of the call; you cannot backdate it. |
| `primaryMember.signedAgreements.privacyPolicy` | boolean | No | Whether the primary member has accepted the privacy policy. **Must be `true`** — an omitted or `false` value fails the whole request before any member is created. |
| `primaryMember.signedAgreements.termsAndConditions` | boolean | No | Whether the primary member has accepted the terms and conditions. **Must be `true`** — an omitted or `false` value fails the whole request before any member is created. |
| `primaryMember.signedAgreements.healthCommitmentStatement` | boolean | No | Whether the primary member has accepted the health commitment statement. Genuinely optional, unlike the other two: `false` or omitted is accepted and simply records no acceptance. |
| `linkedMembers` | array of object | No | Additional members created alongside the primary member and linked to them, such as family on a joint membership. Optional; omit or send an empty array for a single-member join. Every linked member is subject to the same agreement, contact and address rules as the primary. They are linked to the primary member as part of this call, and if any later step fails they are deleted along with the primary. A linked member cannot be given a membership type that applies to links — that is what the primary member's membership does. |
| `linkedMembers[].orderKey` | integer (int32) | No | Which line of the pricing quote this linked member is buying, matching a `pricingQuote.memberships[].orderKey`. At least one member in the request must carry a non-zero order key, or the call fails with `At least one orderKey for the Primary or Linked members must be specified.` Order keys must be unique across the primary and all linked members (`The orderKeys for the Primary and Linked members cannot be repeated.`). Omitting it, or sending `0`, creates the linked member without any membership — useful for a child who is only being added to the account. Unless `hasGuardian` puts the request into guardian mode, every non-zero key must exist in the quote. |
| `linkedMembers[].profile` | object | Yes | Profile cannot be empty. |
| `linkedMembers[].profile.titleId` | string | No | Title ID cannot be more than 15 characters. |
| `linkedMembers[].profile.firstName` | string | Yes | First name cannot be more than 25 characters. |
| `linkedMembers[].profile.email` | string | No | The linked member's email address. Optional only for members young enough: if this is empty, the platform compares the member's `dateOfBirth` against the operator's age-of-online-account setting and rejects the request with `One or more linked members are missing an email address.` if they are old enough to hold their own account. A member with no `dateOfBirth` and no email is treated as old enough and so fails. When supplied it must be a well-formed address, or you get `The email address "{email}"is not a valid email address`. |
| `linkedMembers[].profile.lastName` | string | Yes | Last name cannot be more than 25 characters. |
| `linkedMembers[].profile.dateOfBirth` | string (date-time) | No | Date of Birth cannot be in the future. |
| `linkedMembers[].profile.siteId` | string | No | **Ignored.** Whatever you send here is overwritten with the top-level `siteId` before the member is created, so there is no way to place a member at a different site from the one the quote was priced for. |
| `linkedMembers[].profile.pendingConfirmation` | boolean | No | Marks the member as awaiting confirmation at the moment they are created. It does not survive a successful call: once the memberships are created this endpoint confirms every member it made, so a `201` always leaves confirmed members. It matters only in that a member left behind by a failed rollback may still be in the pending state. |
| `linkedMembers[].profile.memberLanguage` | string | No | **Ignored.** Overwritten with the top-level `memberLanguage` before the member is created. Set the language once at the top level; there is no way to give members in the same request different languages. |
| `linkedMembers[].addressDetails` | object | No | The linked member's addresses, up to two. The whole object is optional — omit it and no address is recorded and no address validation runs at all. But once you supply it, exactly one of `address1` and `address2` must be marked `isPrimary`, or you get `The linked member does not have a primary contact address.` or `… contains multiple primary contact addresses.`, each naming the offending `orderKey`. |
| `linkedMembers[].addressDetails.address1` | object | No | The linked member's first address. Sending it entirely empty while marking it primary is rejected — `The linked member cannot have an address with empty values.` Individual fields are limited to 75 characters, except `postalCode` and `country` at 25. |
| `linkedMembers[].addressDetails.address1.addressLine1` | string | Yes | AddressLine1 cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address1.addressLine2` | string | No | AddressLine2 cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address1.addressLine3` | string | No | AddressLine3 cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address1.townCity` | string | Yes | TownCity cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address1.region` | string | No | Region cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address1.postalCode` | string | Yes | PostalCode cannot be more than 25 characters. |
| `linkedMembers[].addressDetails.address1.country` | string | No | Country cannot be more than 25 characters. |
| `linkedMembers[].addressDetails.address1.isPrimary` | boolean | No | Marks this as the linked member's main address. Exactly one address must carry `true` whenever `addressDetails` is supplied; both `true` or neither `true` fails validation. Defaults to `false` when omitted, so a single address object with this left out will fail. |
| `linkedMembers[].addressDetails.address2` | object | No | A second address for the linked member, such as a term-time or correspondence address. Optional. Subject to the same emptiness and length rules as `address1`, and it counts towards the exactly-one-primary rule. |
| `linkedMembers[].addressDetails.address2.addressLine1` | string | Yes | AddressLine1 cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address2.addressLine2` | string | No | AddressLine2 cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address2.addressLine3` | string | No | AddressLine3 cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address2.townCity` | string | Yes | TownCity cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address2.region` | string | No | Region cannot be more than 75 characters. |
| `linkedMembers[].addressDetails.address2.postalCode` | string | Yes | PostalCode cannot be more than 25 characters. |
| `linkedMembers[].addressDetails.address2.country` | string | No | Country cannot be more than 25 characters. |
| `linkedMembers[].addressDetails.address2.isPrimary` | boolean | No | Marks this as the linked member's main address instead of `address1`. Only one of the two may be `true`. |
| `linkedMembers[].contactDetails` | object | No | The linked member's phone numbers. The whole object is optional — omit it and no numbers are recorded and the primary-number rule is not applied. Supply it and exactly one of `telephone1`, `telephone2` and `mobile` must be marked `isPrimary`: zero gives `The linked member does not have a primary contact phone number.` and more than one gives `The linked member contains multiple primary contact phone numbers.`, each naming the offending `orderKey`. Note the rule counts flags, not numbers: an entry flagged primary with a blank `number` satisfies validation but records nothing. |
| `linkedMembers[].contactDetails.telephone1` | object | No | A landline or general contact number for the linked member. |
| `linkedMembers[].contactDetails.telephone1.number` | string | No | The number itself, as free text. Only entries with a non-empty number are saved, so a blank number here means nothing is recorded even though the entry may still count towards the primary-flag rule. |
| `linkedMembers[].contactDetails.telephone1.isPrimary` | boolean | No | Marks this as the linked member's main contact number. Exactly one of the three numbers must carry `true` whenever `contactDetails` is supplied. Defaults to `false`. |
| `linkedMembers[].contactDetails.telephone2` | object | No | A second landline or alternative contact number for the linked member. |
| `linkedMembers[].contactDetails.telephone2.number` | string | No | The number itself, as free text. A blank number records nothing. |
| `linkedMembers[].contactDetails.telephone2.isPrimary` | boolean | No | Marks this as the linked member's main contact number. Only one number across the three may be `true`. |
| `linkedMembers[].contactDetails.mobile` | object | No | A mobile number for the linked member. |
| `linkedMembers[].contactDetails.mobile.number` | string | No | The number itself, as free text. A blank number records nothing. |
| `linkedMembers[].contactDetails.mobile.isPrimary` | boolean | No | Marks the mobile as the linked member's main contact number. Only one number across the three may be `true`. |
| `linkedMembers[].bankDetails` | object | No | **Ignored.** Only `primaryMember.bankDetails` is used; bank details sent against a linked member are validated for format but never saved and never used to satisfy a direct debit requirement. If a linked member's membership needs a direct debit, put the details on the primary member. |
| `linkedMembers[].bankDetails.accountName` | string | Yes | Maximum 50 characters. |
| `linkedMembers[].bankDetails.accountNumber` | string | Yes | Value must be a string of 8 digits. |
| `linkedMembers[].bankDetails.sortCode` | string | Yes | Value must be a string of 6 digits. |
| `linkedMembers[].userFields` | array of object | No | Values for the operator's custom member fields. Optional; omit or send an empty array when there are none. Each entry must carry both an id and a value — there is no way to send a field with a deliberately blank value through this endpoint. |
| `linkedMembers[].userFields[].userFieldId` | integer (int32) | Yes | User field ID cannot be empty. |
| `linkedMembers[].userFields[].value` | string | Yes | User field value cannot be empty. |
| `linkedMembers[].optInMarketing` | boolean | No | A single switch that opts the linked member in to **every** marketing campaign and **every** communication channel the operator has configured, at `siteId`. There is no per-channel or per-campaign control here: it is all or nothing. Defaults to `false`, which records no consent at all rather than an explicit refusal. |
| `linkedMembers[].signedAgreements` | object | No | Which agreements the linked member has accepted, and the object that makes the call succeed or fail: `privacyPolicy` and `termsAndConditions` must both be `true`. Omitting the object entirely is treated the same as refusing, and fails with `All the linked members need to agree to the privacy policy and terms and conditions.` The date of acceptance is recorded by the platform at the time of the call; you cannot backdate it. |
| `linkedMembers[].signedAgreements.privacyPolicy` | boolean | No | Whether the linked member has accepted the privacy policy. **Must be `true`** — an omitted or `false` value fails the whole request before any member is created. |
| `linkedMembers[].signedAgreements.termsAndConditions` | boolean | No | Whether the linked member has accepted the terms and conditions. **Must be `true`** — an omitted or `false` value fails the whole request before any member is created. |
| `linkedMembers[].signedAgreements.healthCommitmentStatement` | boolean | No | Whether the linked member has accepted the health commitment statement. Genuinely optional, unlike the other two: `false` or omitted is accepted and simply records no acceptance. |
| `pricingQuote` | object | Yes | Pricing quote cannot be empty. |
| `pricingQuote.siteId` | string | Yes | The site the quote was priced for. It must equal the top-level `siteId` exactly, or the request is rejected up front with `The site ID of the member must match the site ID in the membership pricing quote.` Must not be empty. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.promotionCode` | string | No | The promotion code the quote was priced under, if any. Read when creating the memberships to record promotion usage, so removing it both loses that record and breaks the signature. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview` | object | Yes | Pricing quote overview cannot be empty. |
| `pricingQuote.overview.now` | object | No | The total payable immediately across the whole basket, as opposed to the per-membership figures below. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.now.amount` | string | No | The whole-basket amount payable now, as a decimal string with two decimal places, so parse it as a decimal rather than a float. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.now.currency` | string | No | The ISO 4217 currency code of the basket total, resolved from the site's configuration — do not assume `GBP`. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.now.discount` | object | No | The discount applied to the basket total, or `null` when the quote was priced without a promotion code. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.now.discount.priceBeforeDiscount` | string | No | What the basket would have cost without the discount, as a decimal string with two decimal places, so parse it as a decimal rather than a float. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.now.discount.discountValue` | string | No | The amount taken off the basket, as a decimal string with two decimal places, so parse it as a decimal rather than a float. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.now.discount.discountId` | integer (int32) | No | The discount record that was applied. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring` | object | No | The ongoing cost across the whole basket once the initial payment is out of the way. `null` for a quote containing only one-off memberships. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.headlinePrice` | object | No | The headline recurring amount for the basket — the figure the shopper is agreeing to pay each period. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.headlinePrice.amount` | string | No | The recurring basket amount, as a decimal string with two decimal places, so parse it as a decimal rather than a float. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.headlinePrice.currency` | string | No | The ISO 4217 currency code of the recurring amount. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.headlinePrice.discount` | object | No | The discount applied to the recurring amount, or `null` when none applies. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.headlinePrice.discount.priceBeforeDiscount` | string | No | The recurring amount before the discount, as a decimal string with two decimal places, so parse it as a decimal rather than a float. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.headlinePrice.discount.discountValue` | string | No | The amount taken off each recurring payment, as a decimal string with two decimal places, so parse it as a decimal rather than a float. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.headlinePrice.discount.discountId` | integer (int32) | No | The discount record applied to the recurring amount. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.frequency` | string | No | How often the recurring payment is taken. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule` | array of object | No | The forecast payment dates and amounts for the basket as a whole. Presentational — the schedule that drives billing is the per-membership one below. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule[].date` | string (date-time) | No | The date this scheduled basket payment falls due. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule[].totalPrice` | object | No | The total due across the basket on this date. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule[].totalPrice.amount` | string | No | The amount due on this date, as a decimal string with two decimal places, so parse it as a decimal rather than a float. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule[].totalPrice.currency` | string | No | The ISO 4217 currency code for this scheduled payment. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule[].totalPrice.discount` | object | No | The discount applied to this scheduled payment, or `null` when none applies. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule[].breakdown` | array of object | No | How this scheduled basket payment splits across payment methods — one entry per method contributing to the date. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule[].breakdown[].price` | object | No | The portion of this scheduled payment taken by the accompanying payment method, with the same `amount`, `currency` and `discount` shape used elsewhere in the quote. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.overview.recurring.schedule[].breakdown[].paymentMethod` | object | No | The payment method that portion is collected by, carrying its `id`, `description` and `translations`. The `id` matches a payment method from [List all payment methods](../payments/list-all-payment-methods.md). Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships` | array of object | Yes | Memberships cannot be empty. |
| `pricingQuote.memberships[].orderKey` | integer (int32) | No | The line number of this membership within the quote, and the join key to the `orderKey` on `primaryMember` and `linkedMembers` — it is how the platform decides who receives which membership. Unless `hasGuardian` puts the request into guardian mode, every non-zero member `orderKey` must appear here or the call fails with `The orderKeys for the primary and linked members don't match the orderKeys in the given pricing quote.` Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].id` | string | No | The membership type being bought, as returned in `membershipTypes[].id` by [Get membership types grouped by tag](get-membership-types-grouped-by-tag.md). Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].description` | string | No | The membership type's display name as it stood when the quote was priced. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].coolingOffPeriod` | integer (int32) | No | The cancellation cooling-off period configured against this membership type. `null` when the type has none. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].translations` | array of object | No | Localised versions of `description`, one per configured culture. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].translations[].type` | string | No | The record type the translation belongs to. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].translations[].culture` | string | No | The culture code the text is for, such as `en-GB`. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].translations[].text` | string | No | The translated text. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].startDate` | string (date-time) | No | When this membership begins, fixed by the quote. Used for the overlap checks against memberships the member already holds, so it is functional rather than decorative — but it cannot be changed here. To move the start date, get a new quote, and use [Get earliest start date for membership](get-earliest-start-date-for-membership.md) to find a date that will be accepted. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].endDate` | string (date-time) | No | When this membership ends. Also used for the overlap checks; for an auto-renewing membership type the effective end is treated as a hundred years later, so it collides with almost anything of the same priority. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing` | object | No | What this individual membership costs, split into what is due now and what recurs. This is the part of the quote that becomes real money: its contents turn directly into invoice lines and instalments. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now` | object | No | What is payable immediately for this membership — joining fee plus any up-front portion of the subscription. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.totalPrice` | object | No | The total due now for this membership. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.totalPrice.amount` | string | No | The amount due now, as a decimal string with two decimal places, so parse it as a decimal rather than a float. This becomes the total of the sales invoice raised for this membership, so it is the figure the member will actually be charged. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.totalPrice.currency` | string | No | The ISO 4217 currency code for the amount due now. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.totalPrice.discount` | object | No | The discount applied to the amount due now, or `null` when none applies. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.breakdown` | array of object | No | The individual products making up the amount due now. **Each entry becomes a line on the sales invoice.** Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.breakdown[].priceLevelId` | string | No | The price level this line was priced at — the pricing band the member qualified for. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.breakdown[].productId` | string | No | The product being charged for on this line. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.breakdown[].type` | string | No | What kind of charge this line is, which is how the platform separates the recurring subscription product from one-off joining products when building the invoice. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.breakdown[].description` | string | No | The product's display name, used as the invoice line description. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.breakdown[].translations` | array of object | No | Localised versions of the line description, each with `type`, `culture` and `text`. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.now.breakdown[].price` | object | No | The amount for this line, with the same `amount`, `currency` and `discount` shape used elsewhere in the quote. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring` | object | No | The ongoing cost of this membership after the initial payment. `null` for a one-off membership type. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.membershipId` | integer (int32) | No | Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.headlinePrice` | object | No | The headline recurring amount for this membership. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.headlinePrice.amount` | string | No | The recurring amount, as a decimal string with two decimal places, so parse it as a decimal rather than a float. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.headlinePrice.currency` | string | No | The ISO 4217 currency code of the recurring amount. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.headlinePrice.discount` | object | No | The discount applied to the recurring amount, or `null` when none applies. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.frequency` | string | No | How often the recurring payment falls due for this membership. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.schedule` | array of object | No | The forecast future payments for this membership. **The first entry sets up the membership's instalment record**, and the whole array is scanned to decide whether bank details are needed — see `primaryMember.bankDetails`. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.schedule[].date` | string (date-time) | No | When this scheduled payment falls due. The date on the first entry becomes the membership's first instalment date. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.memberships[].pricing.recurring.schedule[].payments` | array of object | No | The payments due on this date, each carrying a `price` and the `paymentMethod` collecting it. The payment method ids here are what decide whether `primaryMember.bankDetails` is compulsory. Part of the signed quote: send it back exactly as the pricing quote gave it to you. Altering or omitting it breaks `signature` and the call fails with `The pricing quote is invalid.` |
| `pricingQuote.timeStamp` | string | Yes | When the quote was issued, as returned by the pricing call. It is the expiry clock: the request is rejected once more than **one hour** has passed (`The time frame where the request is considered valid has expired.`), or if it is more than **ten minutes** ahead of the server's UTC clock (`The time stamp of the request can not be in the future.`). Both comparisons use the server's time, not yours. Must not be empty, and is inside the signature so it cannot be adjusted. |
| `pricingQuote.signature` | string | Yes | The keyed hash computed over the rest of the quote when it was issued. The platform recomputes it from what you send, with `signature` itself excluded, and rejects any mismatch with `The pricing quote is invalid.` Opaque — do not decode, regenerate or re-case it; the signing key is not issued to integrators. If you hit this error, something inside `pricingQuote` was normalised, rounded or dropped in transit. Must not be empty. |
| `hasGuardian` | boolean | No | Declares that the primary member is a guardian buying on behalf of the linked members rather than for themselves. Defaults to `false`. Setting it `true` only takes effect when there is at least one linked member, `siteId` matches the quote, and every linked member's order key is the one the quote's memberships carry; when all of that holds, the check that member order keys appear in the quote is skipped and the members are linked in guardian mode. In every other case a `true` here has no effect — so do not rely on it to bypass order key validation. |
| `memberLanguage` | string | No | The language to record against every member created by this call. It overwrites any `memberLanguage` set on an individual profile, so all members in one request get the same value. |

## Sample request

```bash title="cURL"
curl -X POST '{base_url}/api/memberships/add-members-with-memberships' \
  -H 'Authorization: Bearer {accessToken}' \
  -H 'X-Use-SSO: true' \
  -H 'Content-Type: application/json' \
  -d '{
  "siteId": "string",
  "primaryMember": {
    "orderKey": 1,
    "profile": {
      "titleId": "string",
      "firstName": "string",
      "email": "member@example.com",
      "lastName": "string",
      "dateOfBirth": "2026-08-14T19:00:00Z",
      "siteId": "string",
      "pendingConfirmation": true,
      "memberLanguage": "string"
    },
    "addressDetails": {
      "address1": {
        "addressLine1": "string",
        "addressLine2": "string",
        "addressLine3": "string",
        "townCity": "string",
        "region": "string",
        "postalCode": "string",
        "country": "string",
        "isPrimary": true
      },
      "address2": {
        "addressLine1": "string",
        "addressLine2": "string",
        "addressLine3": "string",
        "townCity": "string",
        "region": "string",
        "postalCode": "string",
        "country": "string",
        "isPrimary": true
      }
    },
    "contactDetails": {
      "telephone1": {
        "number": "string",
        "isPrimary": true
      },
      "telephone2": {
        "number": "string",
        "isPrimary": true
      },
      "mobile": {
        "number": "string",
        "isPrimary": true
      }
    },
    "bankDetails": {
      "accountName": "string",
      "accountNumber": "string",
      "sortCode": "string"
    },
    "userFields": [
      {
        "userFieldId": 12345
      }
    ],
    "optInMarketing": true,
    "signedAgreements": {
      "privacyPolicy": true,
      "termsAndConditions": true,
      "healthCommitmentStatement": true
    }
  },
  "linkedMembers": [
    {
      "orderKey": 1,
      "profile": {
        "titleId": "string",
        "firstName": "string",
        "email": "member@example.com",
        "lastName": "string",
        "dateOfBirth": "2026-08-14T19:00:00Z",
        "siteId": "string",
        "pendingConfirmation": true,
        "memberLanguage": "string"
      },
      "addressDetails": {
        "address1": {
          "addressLine1": "string",
          "addressLine2": "string",
          "addressLine3": "string",
          "townCity": "string",
          "region": "string",
          "postalCode": "string",
          "country": "string",
          "isPrimary": true
        },
        "address2": {
          "addressLine1": "string",
          "addressLine2": "string",
          "addressLine3": "string",
          "townCity": "string",
          "region": "string",
          "postalCode": "string",
          "country": "string",
          "isPrimary": true
        }
      },
      "contactDetails": {
        "telephone1": {
          "number": "string",
          "isPrimary": true
        },
        "telephone2": {
          "number": "string",
          "isPrimary": true
        },
        "mobile": {
          "number": "string",
          "isPrimary": true
        }
      },
      "bankDetails": {
        "accountName": "string",
        "accountNumber": "string",
        "sortCode": "string"
      },
      "userFields": [
        {
          "userFieldId": 12345
        }
      ],
      "optInMarketing": true,
      "signedAgreements": {
        "privacyPolicy": true,
        "termsAndConditions": true,
        "healthCommitmentStatement": true
      }
    }
  ],
  "pricingQuote": {
    "siteId": "string",
    "promotionCode": "string",
    "overview": {
      "now": {
        "amount": "string",
        "currency": "GBP",
        "discount": {
          "priceBeforeDiscount": "string",
          "discountValue": "string",
          "discountId": 12345
        }
      },
      "recurring": {
        "headlinePrice": {
          "amount": "string",
          "currency": "GBP",
          "discount": {
            "priceBeforeDiscount": "string",
            "discountValue": "string",
            "discountId": 12345
          }
        },
        "frequency": "string",
        "schedule": [
          {
            "date": "2026-08-14T19:00:00Z",
            "totalPrice": {
              "amount": "string",
              "currency": "GBP",
              "discount": {
                "priceBeforeDiscount": "string",
                "discountValue": "string",
                "discountId": 12345
              }
            },
            "breakdown": [
              {
                "price": {
                  "amount": "string",
                  "currency": "GBP",
                  "discount": {}
                },
                "paymentMethod": {
                  "id": "string",
                  "description": "string",
                  "translations": []
                }
              }
            ]
          }
        ]
      }
    },
    "memberships": [
      {
        "orderKey": 1,
        "id": "string",
        "description": "string",
        "coolingOffPeriod": 1,
        "translations": [
          {
            "type": "string",
            "culture": "string",
            "text": "string"
          }
        ],
        "startDate": "2026-08-14T19:00:00Z",
        "endDate": "2026-08-14T19:00:00Z",
        "pricing": {
          "now": {
            "totalPrice": {
              "amount": "string",
              "currency": "GBP",
              "discount": {
                "priceBeforeDiscount": "string",
                "discountValue": "string",
                "discountId": 12345
              }
            },
            "breakdown": [
              {
                "priceLevelId": "string",
                "productId": "string",
                "type": "string",
                "description": "string",
                "translations": [
                  {}
                ],
                "price": {
                  "amount": "string",
                  "currency": "GBP",
                  "discount": {}
                }
              }
            ]
          },
          "recurring": {
            "membershipId": 12345,
            "headlinePrice": {
              "amount": "string",
              "currency": "GBP",
              "discount": {
                "priceBeforeDiscount": "string",
                "discountValue": "string",
                "discountId": 12345
              }
            },
            "frequency": "string",
            "schedule": [
              {
                "date": "2026-08-14T19:00:00Z",
                "payments": [
                  {}
                ]
              }
            ]
          }
        }
      }
    ],
    "timeStamp": "string",
    "signature": "string"
  },
  "hasGuardian": true,
  "memberLanguage": "string"
}'
```

## Response fields

| Field | Type | Description |
|-------|------|-------------|
| `membersCreated` | array of object | One entry per member this call created, including any created without a membership. Match entries to your request by `orderKey`, except for members you sent with no order key — those come back with `orderKey` `0` and can only be identified by elimination, so keep the creation order if you need to tell them apart. |
| `membersCreated[].orderKey` | integer (int32) | The order key you sent for this member, echoed back. `0` for a member created without a membership, and `0` for a primary member sent without one — so it is not a reliable identifier on its own. |
| `membersCreated[].memberId` | integer (int32) | The platform's id for the newly created member. This is the value you pass as `memberId` when paying their invoices, and the handle you need if a later failure leaves the account to be cleaned up. Persist it before doing anything else. |
| `membersCreated[].bankAccountReference` | string | The reference for the bank account stored against the primary member, returned only when bank details were both supplied and actually needed by the quote. `null` on every linked member, and `null` on the primary member when the purchase needed no direct debit — which is not an error. |
| `membersCreated[].salesInvoiceIds` | array of integer | The unpaid invoices raised for this member. Pass them, together with this entry's `memberId`, to [Pay for sales invoices](../payments/pays-for-sales-invoices-using-a-specified-payment-provider-and-can-handle-paymen.md) — that endpoint is scoped to one member, so a family join needs one payment call per member with a non-empty list here. Empty for a member created without a membership. |
| `membershipsCreated` | array of object | One entry per membership created, which is the view you need when a single member bought more than one. A member appears once here per membership but only once in `membersCreated`. |
| `membershipsCreated[].memberId` | integer (int32) | The member this membership belongs to, matching a `membersCreated[].memberId`. |
| `membershipsCreated[].membershipIsDirectDebit` | boolean | Whether this membership collects by direct debit. Even when `true`, the initial invoice still has to be paid through the payment endpoint — the direct debit governs the recurring collections that follow, and is why bank details were required for the call to succeed. |
| `membershipsCreated[].membershipId` | integer (int32) | The platform's reference for the membership record created. This is a new membership instance, not the membership type id you sent in the quote. |
| `membershipsCreated[].salesInvoiceIds` | array of integer | The invoices raised for this membership alone. The union of these across a member equals that member's `membersCreated[].salesInvoiceIds`. |
| `timeStamp` | string | When this response was produced, in the same form as the pricing quote's timestamp. It is covered by `signature` and exists so the response can be shown to be fresh rather than replayed. |
| `signature` | string | A keyed hash over this response, computed the same way the pricing quote is signed. It lets a downstream step confirm the response has not been tampered with. You cannot verify it yourself — the signing key is not issued to integrators — so treat it as data to pass on rather than to check. |

## Sample response

```json title="201 Created"
{
  "membersCreated": [
    {
      "orderKey": 1,
      "memberId": 12345,
      "bankAccountReference": "string",
      "salesInvoiceIds": [
        12345
      ]
    }
  ],
  "membershipsCreated": [
    {
      "memberId": 12345,
      "membershipIsDirectDebit": true,
      "membershipId": 12345,
      "salesInvoiceIds": [
        12345
      ]
    }
  ],
  "timeStamp": "string",
  "signature": "string"
}
```

## Errors

| Status | Meaning | When |
|--------|---------|------|
| `400` | Bad request | `The site ID of the member must match the site ID in the membership pricing quote.` — checked first, before anything else. |
| `400` | Bad request | `The pricing quote is invalid.` — the recomputed signature does not match, because something inside `pricingQuote` was altered, reformatted or dropped. |
| `400` | Bad request | `The time frame where the request is considered valid has expired.` — more than an hour since `pricingQuote.timeStamp`. Get a fresh quote. |
| `400` | Bad request | `The time stamp of the request can not be in the future.` — the quote's timestamp is over ten minutes ahead of the server's UTC clock. |
| `400` | Bad request | `The primary member's email must be supplied and be less than 256 characters.` |
| `400` | Bad request | `One or more linked members are missing an email address.` — a linked member with no email is at or above the operator's age-of-online-account threshold. A linked member with no `dateOfBirth` and no email also trips this. |
| `400` | Bad request | `The email address "{email}"is not a valid email address` — a linked member's address is malformed. |
| `400` | Bad request | `At least one orderKey for the Primary or Linked members must be specified.` or `The orderKeys for the Primary and Linked members cannot be repeated.` |
| `400` | Bad request | `The orderKeys for the primary and linked members don't match the orderKeys in the given pricing quote.` — skipped only when `hasGuardian` puts the request into guardian mode. |
| `400` | Bad request | Contact number rules: `The primary member needs to have one primary contact phone number.`, `The primary member cannot have multiple primary contact phone numbers.`, or the linked-member equivalents naming an `orderKey`. Only applied when `contactDetails` is present. |
| `400` | Bad request | Address rules: `The primary member needs to have exactly one primary contact address.`, `The primary contact address of the primary member cannot be empty.`, or the linked-member equivalents naming an `orderKey`. Only applied when `addressDetails` is present. |
| `400` | Bad request | `The primary member needs to agree to the privacy policy and terms and conditions.` or `All the linked members need to agree to the privacy policy and terms and conditions.` |
| `400` | Bad request | `Membership types "{ids}" require the primary member to provide bank details` — the quote's recurring schedule names a BACS direct debit payment method and `primaryMember.bankDetails` is absent. |
| `400` | Bad request | `The primary member's memberCounter must be 0` — an internal field was populated on the request. Do not send `memberCounter`. |
| `400` | Bad request | Field validation failures: `Site ID cannot be empty`, `Site ID cannot be more than 15 characters`, `PrimaryMember cannot be empty`, `Profile cannot be empty`, `Pricing quote cannot be empty`, `First name cannot be empty`, `First name cannot be more than 25 characters`, `Last name cannot be empty`, `Last name cannot be more than 25 characters`, `Title ID cannot be more than 15 characters`, `Date of Birth cannot be in the future`, `AddressLine1 cannot be more than 75 characters` and its siblings, `Value must be a string of 8 digits.`, `Value must be a string of 6 digits.`, `User field ID cannot be empty`, `User field value cannot be empty`. |
| `400` | Bad request | A later step rejected the data — for example a member that duplicates one already on file, or a membership rule such as a duplicate or concurrent membership. The message describes what was refused, and the members created so far have been deleted. |
| `422` | Unprocessable entity | A later step could not complete: a member's contact details could not be updated because of a concurrent change, or a membership could not be created. Also returned when the rollback could not identify a primary member to remove, which means members may remain. |
| `500` | Internal server error | An unexpected server-side failure, **or a failure whose rollback itself failed**. In the second case members have been created and not removed: look them up by the details you submitted and delete them before retrying, or you will hit duplicate-member validation. The body is empty in this case. |
| `503` | Service unavailable | A service this call depends on could not be reached — including `Failed to retrieve payment method configuration.` when the bank details requirement cannot be determined. Members created so far are rolled back. |
