Creating Contracts
Learn how to create invoiced contracts, including multi-seat contracts, through the Management API without sending the customer through a checkout.
Overview
A contract created through the Management API is the same thing as one created with Create contract in the Sesamy portal. You pick a product and a purchase option, name who gets access, and Sesamy invoices the customer. It suits B2B sales, where a deal is agreed outside the checkout and often at a negotiated price.
Creating a contract takes two steps:
- Create the contract. It comes back as
PENDING: priced, but not billed and granting no access. - Confirm it. Sesamy sends the invoice and grants access.
The gap between the two lets you check the price and the discounts before an invoice reaches your customer. You can do both in one request with confirm: true.
Creating a contract is not idempotent
A repeated POST /management/contracts creates a second contract. If a request times out, look the contract up with GET /management/contracts?userId=... before you retry. Confirming is safe to repeat: confirming an already confirmed contract returns it unchanged.
Base URL
https://api2.sesamy.comAuthentication
Both endpoints need a Management API token with the contracts:write scope. Request the token from https://token.sesamy.com/oauth/token with the client credentials flow, and send it as a bearer token:
Authorization: Bearer YOUR_ACCESS_TOKENSee Authentication for how to obtain a token.
Getting access
The contracts:write scope is not part of the API key presets. Contact Sesamy to have it enabled for your client.
Create a Contract
POST https://api2.sesamy.com/management/contracts
Creates a PENDING contract. The product must belong to your vendor.
| Field | Type | Description |
|---|---|---|
sku | string | The product to sell |
purchaseOptionId | string | The purchase option of that product |
contractType | string | (Optional) PERSONAL or BUSINESS. Defaults to PERSONAL |
user | object | Who gets access. Sesamy creates a user for the email if none exists |
user.email | string | The user's email address |
user.firstName | string | (Optional) |
user.lastName | string | (Optional) |
user.phone | string | (Optional) |
user.businessDetails | object | (Optional) companyName and vatNr |
user.address | object | (Optional) Billing address: street, zip, city, and a two-letter country, plus optional firstName, lastName, and co |
user.deliveryAddress | object | (Optional) Same shape as address |
payer | object | (Optional) Who is billed, when that is someone other than user. Same shape as user |
invoice.email | string | (Optional) Where invoices are sent. Defaults to the email of whoever is billed |
invoice.customerReference | string | (Optional) Your customer's reference, printed on the invoice as their reference. For example a purchase order number |
invoice.vendorReference | string | (Optional) Your own reference, printed on the invoice as your reference. For example the seller. At most 34 characters |
invoice.ediNumber | string | (Optional) Delivers the invoice as an e-invoice (EDI/EHF) to this number |
quantity | integer | (Optional) Billed units. Defaults to 1. Must be within the limits of the purchase option |
seats | integer | (Optional) Total seats. License products only. See Multi-Seat Contracts |
unitPrice.amount | number | (Optional) Overrides the list price per unit, in the currency of the purchase option. Applies to every bill, not only the first |
unitPrice.taxIncluded | boolean | (Optional) Whether unitPrice.amount includes VAT. Defaults to the setting of the purchase option |
discountCodes | string[] | (Optional) Discount codes to apply |
payment.provider | string | BILLOGRAM |
payment.method | string | INVOICE |
payment.country | string | (Optional) Two-letter country the payment is made from. Decides the market and the VAT. Defaults to SE |
startsAt | string | (Optional) ISO 8601 date to start the contract on. Until then the contract is FORTHCOMING, and access is granted when it starts |
anchorDate | string | (Optional) ISO 8601 date to align the billing cycle to |
confirm | boolean | (Optional) Confirm in the same request. Defaults to false |
Prices are in major units, as everywhere else in the API: send 9999 for 9,999.00 SEK.
Why only invoices
A contract created from your server has nobody present to approve a card or a wallet payment, so it could be created but never confirmed. Send the customer through a checkout for those payment methods.
Example Request
curl -X POST "https://api2.sesamy.com/management/contracts" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sku": "sid:acme-plus-business",
"purchaseOptionId": "acme-plus-business-yearly",
"contractType": "BUSINESS",
"user": {
"email": "jane.doe@acme.example",
"firstName": "Jane",
"lastName": "Doe",
"businessDetails": { "companyName": "ACME AB", "vatNr": "SE556000000001" },
"address": {
"street": "Example Street 1",
"zip": "12345",
"city": "Stockholm",
"country": "SE"
}
},
"invoice": {
"email": "invoices@acme.example",
"customerReference": "PO-1042",
"vendorReference": "John Doe"
},
"quantity": 1,
"seats": 10,
"unitPrice": { "amount": 9999, "taxIncluded": true },
"payment": { "provider": "BILLOGRAM", "method": "INVOICE", "country": "SE" }
}'const contract = await mgmtSdk.contracts.create({
sku: 'sid:acme-plus-business',
purchaseOptionId: 'acme-plus-business-yearly',
contractType: 'BUSINESS',
user: {
email: 'jane.doe@acme.example',
firstName: 'Jane',
lastName: 'Doe',
businessDetails: { companyName: 'ACME AB', vatNr: 'SE556000000001' },
},
invoice: { email: 'invoices@acme.example', customerReference: 'PO-1042' },
quantity: 1,
seats: 10,
unitPrice: { amount: 9999, taxIncluded: true },
payment: { provider: 'BILLOGRAM', method: 'INVOICE', country: 'SE' },
});Example Response
Status: 201 Created
{
"id": "M-V1StGXR8_Z5jdHi6B-myT",
"name": "Contract for jane.doe@acme.example from portal",
"userId": "auth2|user_123",
"status": "PENDING",
"isActive": false,
"price": 9999,
"currency": "SEK",
"contractDuration": "RECURRING",
"recurringInterval": "YEAR",
"recurringTime": 1,
"origin": { "type": "MANUAL", "id": "V1StGXR8_Z5jdHi6B-myT" },
"items": [
{
"sku": "sid:acme-plus-business",
"name": "ACME Plus Business",
"purchaseOptionId": "acme-plus-business-yearly",
"productType": "license",
"isMultiseat": true,
"quantity": 1,
"seats": 10
}
],
"appliedDiscounts": [],
"discount": null
}Check the Discounts Before You Confirm
A discount code that does not apply never fails the request. The contract is created at full price, and the code is listed with "status": "UNAPPLICABLE" in appliedDiscounts. Check it before you confirm.
With confirm: true there is no moment to check, so the API does it for you: if a code could not be applied, the request fails with 400, and the contract is left PENDING and unbilled.
Confirm a Contract
POST https://api2.sesamy.com/management/contracts/{id}/confirm
Confirms a PENDING contract. Sesamy sends the invoice and grants access, including the seats of a multi-seat contract. The request has no body.
curl -X POST "https://api2.sesamy.com/management/contracts/M-V1StGXR8_Z5jdHi6B-myT/confirm" \
-H "Authorization: Bearer $ACCESS_TOKEN"const confirmed = await mgmtSdk.contracts.confirm('M-V1StGXR8_Z5jdHi6B-myT');The response is the contract, now ACTIVE. A contract with a startsAt in the future becomes FORTHCOMING instead, and turns ACTIVE on that date.
| Status | Meaning |
|---|---|
200 | The contract is confirmed. Also returned for a contract that was already confirmed |
400 | The payment provider refused. The message says why |
404 | No such contract for your vendor |
409 | The contract is in a status that cannot be confirmed, such as CANCELED |
Multi-Seat Contracts
A multi-seat contract gives several people access under one contract. To create one, sell a product of the License type. Two fields decide its size:
quantityis what you bill. The price isquantity× the unit price.seatsis how many people get access. Leave it out and the contract gets the seats per unit of the purchase option ×quantity. Set it to grant a different number of seats without changing the price.
A flat price for a fixed number of seats is quantity: 1 with unitPrice and seats set, as in the example above: one unit, 9,999.00 SEK, ten seats. To bill per seat instead, send quantity: 10 with the price of one seat.
The user administers the seats and holds the first one. To see everyone assigned a seat, call GET /management/contracts/{id}/seats/users.
Setting seats on a product that is not a license fails with 400.
Change the Seats
PUT https://api2.sesamy.com/management/contracts/{id}/seats
Sets the total number of seats on a contract that is ACTIVE, FORTHCOMING or OVERDUE. Billing stays the same: quantity and the price are untouched, and the change applies at once. To bill for the extra seats, change the price separately.
curl -X PUT "https://api2.sesamy.com/management/contracts/M-V1StGXR8_Z5jdHi6B-myT/seats" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "seats": 25 }'const contract = await mgmtSdk.contracts.setSeats('M-V1StGXR8_Z5jdHi6B-myT', { seats: 25 });| Field | Type | Description |
|---|---|---|
seats | integer | The total number of seats, at least 1 |
sku | string | (Optional) The license item to change. Only needed when the contract has more than one |
The response is the contract with the new seats on the item. Setting the number of seats the contract already has returns it unchanged, so the request is safe to repeat. People get their seats shortly after the change, not in the same request.
You cannot lower the seats below the number of people holding one. List and remove seat users first.
| Status | Meaning |
|---|---|
200 | The seats are set |
400 | The body is invalid, the contract has no license item, sku does not match an item, or sku is missing on a contract with more than one license item |
404 | No such contract for your vendor |
409 | The contract is not active or is set to cancel, more people hold a seat than seats, or Sesamy could not confirm that the price stays the same |
Manage Seat Users
Use these routes to manage who holds the seats on a multi-seat contract:
| Method | Route | Purpose |
|---|---|---|
GET | /management/contracts/{id}/seats/users | List seat users. Pass ?sku=... to filter by license item; omit it to list all items. |
POST | /management/contracts/{id}/seats/users | Assign a seat by email. Returns 204. |
DELETE | /management/contracts/{id}/seats/users/{userId} | Remove a seat user by ID. Returns 204. |
The list returns each user's userId, sku, status, and entitlementId, plus email and fullName when available. Removed seats are left out. The sku is the content product the seat unlocks, so it can differ from the license product SKU on the contract item. For example, a seat on sid:acme-plus-business-multi is listed as sid:acme-plus-business. The sku filter accepts either form. Use the returned userId for removal. These routes require contracts:read for listing and contracts:write for assigning or removing.
curl "https://api2.sesamy.com/management/contracts/M-V1StGXR8_Z5jdHi6B-myT/seats/users" \
-H "Authorization: Bearer $ACCESS_TOKEN"
curl -X POST "https://api2.sesamy.com/management/contracts/M-V1StGXR8_Z5jdHi6B-myT/seats/users" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "email": "reader@example.com" }'
curl -X DELETE "https://api2.sesamy.com/management/contracts/M-V1StGXR8_Z5jdHi6B-myT/seats/users/auth0%7Creader" \
-H "Authorization: Bearer $ACCESS_TOKEN"await mgmtSdk.contracts.addSeatUser(contractId, { email: 'reader@example.com' });
const users = await mgmtSdk.contracts.listSeatUsers(contractId);
const reader = users.find((user) => user.email === 'reader@example.com');
if (reader) await mgmtSdk.contracts.removeSeatUser(contractId, reader.userId);When every seat is taken, POST returns 409. GET /management/contracts/{id}/licenses remains available for existing integrations but is deprecated.
User and Payer
user is who gets access. payer is who gets the bill. Leave payer out when they are the same, which is the common case: put the company details and the billing address on user.
Set payer when the invoice goes to someone else, such as a finance contact or a parent company. Invoices are then addressed to the payer on behalf of the user, and invoice.email applies to the payer. The contract names the payer in its payer field:
{
"userId": "auth2|user_123",
"payer": { "id": "auth2|user_456", "email": "finance@acme.example" }
}Errors
| Status | When |
|---|---|
400 | The body is invalid, the purchase option does not exist, quantity is outside its limits, seats is set on a product that is not a license, the payment method is not available to you, or a discount code could not be applied with confirm: true |
403 | The token lacks the contracts:write scope |
404 | The product does not exist, or belongs to another vendor |
503 | Sesamy could not reach the payment service. The contract may or may not exist: look it up before you retry |
See Errors for the shape of an error response.