Save or submit a contract
Save the contract form as a draft, or submit it. A submit checks the whole body and moves the contract to Contract-Initiated, ready to send for signature. Save data.contractId from the response: every later contract call needs it.
One endpoint covers two calls:
- Save as draft (
isDraft: true). Stores the whole body on the contract record and sets the stage toDraft. The only checks are thatcontractDetails.enterpriseIdis present and thatmanagerName, if sent, is an allowed value. - Submit (
isDraft: false). Checks the whole body, then:- rejects the request if any rooftop already has a
ContractedorContract-Livecontract, - rejects a second active Individual Dealer contract for the same dealer group,
- stores the body on the contract and sets the stage to
Contract-Initiated, - starts the contract signing history,
- writes a stage log entry,
- moves the dealer group stage to
Contract-Initiated.
- rejects the request if any rooftop already has a
No rooftops, product lines or plans are created at this point. They are created only after the contract is fully signed (see Receive DocuSign events).
If you leave out contractId, a new contract record is created from the default contract template. Its file location (contracts/ENTERPRISE_ID/CONTRACT_ID.pdf) is set at the same time. If you send contractId, that contract must belong to the same dealer group and still be in Draft.
Amendments (isAmendment: true) and Reseller contracts (customerType: "Reseller") use this endpoint too, but they follow separate validation paths that this page does not cover.
Send the bearer token of a signed-in Spyne Console user in Authorization: Bearer YOUR_TOKEN. The token is checked against the user validation endpoint on every call. The user ID from the token is saved as the contract's updatedBy.
Before you call it
- The dealer group (
enterpriseId) must already exist. If it cannot be found, a submit returns400"Enterprise not found". - Call Get contract config first. Every dropdown value must come from it, and
pricingmust be itsdata.pricing. contractDetails.currencymust have a stored conversion rate from USD. This applies toUSDtoo.- If the country is India, the currency must be
INR. - An Individual Dealer contract has exactly one rooftop. A Group Dealer contract needs
customerDetails.contractedRooftopsandcustomerDetails.billingTo. - Rooftop names must be unique within the contract (case-insensitive).
- Every
rooftops[].bundlemust match a key inline_items.value. - Product line names and product names must exist in the product line registry and the product registry. Known Vini AI product names used by the console are
salesInbound,salesOutbound,serviceInbound,serviceOutbound,receptionWebChatandacquisitionAiOutbound. - The call is synchronous. It waits for validation and the contract write. The history, stage log and dealer group stage updates also run before the response, but if they fail the request still succeeds. The rate limit is not documented.
Request
- Authenticate: send the
Authorization: Bearer YOUR_TOKENandContent-Type: application/jsonheaders. - Draft or submit: set
isDrafttotrueorfalse. It is required on submit. - New or existing: to continue a draft, send its
contractId. Leave it out to start a new contract. - Fill the details: send
contractDetails,customerDetails,signatoryDetails,billingDetailsandterms. - Copy the pricing: copy
data.pricingfrom Get contract config intopricing. - Build the rooftops: one
rooftops[]entry per rooftop. Each needs address fields,subType,bundleand at least one product line with at least one product. - Price the bundles: build
line_itemswith one bundle perbundlekey used by the rooftops. - Send totals: send
summarytotals, each a number with at most 2 decimal places. - Keep the ID: read
data.contractIdfrom the response for Send for signature.
Headers
| Parameter | Type | Required | Description |
|---|---|---|---|
Authorization | string | Required | Bearer YOUR_TOKEN |
Content-Type | string | Required | application/json |
Body
"Required if submitting" means required when isDraft is false.
| Parameter | Type | Required | Description |
|---|---|---|---|
isDraft | boolean | Required | true saves a draft. false submits the contract. |
contractId | string | Optional | ID of an existing Draft contract to update. Leave it out to create a new contract. |
isAmendment | boolean | Optional | true routes to the amendment flow (not covered here). The console also sends amendedFromContractId with it. |
contractDetails | object | Required | Deal-level details. |
contractDetails.enterpriseId | string | Required | ID of the dealer group. Required for drafts too. |
contractDetails.currency | string | Required | Currency code. Must exist in the stored conversion rates. Must be INR when customerDetails.country is India. |
contractDetails.dealType | string | Required | One of data.basicDetails.dealTypes. Currently only New Deal. |
contractDetails.customerType | string | Required | Individual Dealer, Group Dealer or Reseller. You cannot switch a dealer group from Group Dealer to Individual Dealer while it has an active contract. |
contractDetails.managerName | string | Optional | Account manager. Empty or missing is allowed. If set, it must be one of data.basicDetails.managerNames. |
customerDetails | object | Required if | Submitting. Legal entity and address of the dealer group. |
customerDetails.legalName | string | Required | Legal name of the dealer group. |
customerDetails.brandName | string | Required | Trading name. |
customerDetails.region | string | Required | AMER, APAC, EMEA or OTHERS. |
customerDetails.streetAddress | string | Required | Street address. |
customerDetails.country | string | Required | Country. |
customerDetails.pincode | string | Required | Postal (ZIP) code, sent as a string. |
customerDetails.state | string | Required | State. |
customerDetails.city | string | Required | City. |
customerDetails.stateCode | string | Optional | The console sends it only when the country is India. |
customerDetails.contractedRooftops | number | Required if | Group Dealer. Number of contracted rooftops. Must be greater than 0 when sent. |
customerDetails.billingTo | string | Required if | Group Dealer. Must be one of data.customerDetails.billingTo. Currently only Group Dealer (Combined). |
pricing | object | Required if | Submitting. The pricing catalog from data.pricing of Get contract config. It must be a non-empty object and must contain an entry for every product line used in rooftops. |
rooftops | object[] | Required if | Submitting. At least one rooftop. |
rooftops[].rooftopName | string | Required | Rooftop name. Unique within the contract. For an Individual Dealer, the console falls back to the legal name. |
rooftops[].subType | string | Required | Independent Dealer, Franchise Dealer or Mixed. |
rooftops[].region | string | Required | AMER, APAC, EMEA or OTHERS. The console copies it from customerDetails. |
rooftops[].streetAddress, country, pincode, state, city | string | Required | Rooftop address. The console copies each from customerDetails. |
rooftops[].bundle | string | Required | Key of the bundle in line_items.value that prices this rooftop (case-insensitive), for example bundle-1. |
rooftops[].teamId | string | Optional | Existing rooftop ID. The console sends it only for amendments. On submit, if it is missing, the rooftop is looked up by name in the dealer group. |
rooftops[].existingInventory | number | Optional | Must be a number when sent. The console sends this value inside the Studio AI product line instead. |
rooftops[].productsLines | object[] | Required | At least one product line. |
productsLines[].productLineName | string | Required | Product line name from the registry, for example conversationalAi (Vini AI) or studioAi (Studio AI). |
productsLines[].plan | string | Required if | studioAi. Studio AI plan, Lite or Pro. Every product must be allowed in that plan. The console sends an empty string for conversationalAi. |
productsLines[].products | object[] | Required | At least one product. Each has the four fields below. |
products[].product | string | Required | Product name from the product registry, for example salesInbound or serviceInbound. |
products[].units | string | Required | Volume tier as a positive number in a string, for example "150". |
products[].unitType | string | Required | Unit of units. The console sends vinsPerMonth, leadsPerMonth (sales outbound, service outbound) or roPerMonth (service inbound). |
products[].cost | number | Required | Monthly cost for this product. It must be a number. It is not checked against the catalog. |
productsLines[].oneTimePurchases | string[] | Optional | One-time charges the console adds, for example oneTimeSetupFee. |
productsLines[].addOns | string[] | Optional | Studio AI add-on names the console adds. |
productsLines[].existingInventory | number | Optional | Studio AI only. Existing inventory count the console uses for the setup fee. |
signatoryDetails | object | Required if | Submitting. The dealer-side person who signs. |
signatoryDetails.name | string | Required | Signer name. |
signatoryDetails.email | string | Required | Signer email. Must be a valid email. DocuSign sends the envelope here. |
signatoryDetails.designation | string | Required | Signer job title. |
signatoryDetails.contactNumber | object | Required | { "isdCode": "+1", "number": "5550100000" }. Both parts are required strings. |
signatoryDetails.clientPOCs | string[] | Optional | Extra dealer emails to copy on the DocuSign envelope. Invalid emails are skipped. |
billingDetails | object | Required if | Submitting. Billing frequency and finance contact. |
billingDetails.billingFrequency | number | Required | 1, 3, 6 or 12. |
billingDetails.financePOCName | string | Required | Finance contact name. |
billingDetails.financePOCEmail | string | Required | Finance contact email. Must be a valid email. |
billingDetails.financePOCNumber | object | Required | { "isdCode": "+1", "number": "5550100000" }. Both parts are required strings. |
billingDetails.billingTo | string | Optional | The console copies customerDetails.billingTo here when it is set. |
billingDetails.billingEntity | string | Optional | Billing entity from data.billingEntities. To be confirmed: whether the key or the label is expected. |
billingDetails.taxId | string | Optional | Tax ID. The console trims it and sends it only when it is not empty. |
terms | object | Required if | Submitting. Contract terms. |
terms.t&cTemplatePath | string | Required | Terms template path, normally data.defaultTcTemplatePathPdf from the config call. |
terms.editT&C | boolean | Required | true if custom terms were uploaded. |
terms.exception | boolean | Required | true if the deal is raised as an exception. The console shows the signing step only when this is false. |
terms.contractTerm | number | Required | Contract term. The unit is not documented in code. |
terms.lockInPeriod | number | Required | Lock-in period. The unit is not documented in code. |
terms.noticePeriodForAutoRenewal | number | Required | Notice period for auto renewal. The unit is not documented in code. |
terms.terminationDays | number | Optional | The console sends a value from data.terminationDays (30 or 60). Not checked on submit. |
terms.freeDays | number | Optional | Must be a number when sent. |
line_items | object | Required if | Submitting. Priced line items grouped by bundle. |
line_items.type | string | Required | Must be bundle. |
line_items.value | object | Required | One entry per bundle key, for example bundle-1. It needs at least one bundle. |
value.BUNDLE.studioAi | object | Optional | Sections studio and addOns, each an array of line items. |
value.BUNDLE.conversationalAi | object | Optional | Sections named after the product categories in the registry, plus addOns, each an array of line items. The console sends salesAgent, serviceAgent, receptionAgent, acquisitionAgent and addOns. A section that is not a registry category is not checked. |
value.BUNDLE.rooftopsCount | number | Optional | The console sends the number of rooftops in the bundle. Not checked. |
value.BUNDLE.existingInventoryPerRooftop | number | Optional | The console sends this value. Not checked. |
| Each line item in a section | — | — | The fields below. |
type | string | Required | Line label, for example Inbound, Outbound, PRO Plan, Setup Fee or oneTimeSetupFee. |
frequency | string | Required | Monthly, One-Time, Annually, Quarterly or Half-Yearly. |
basePrice | number | Required | List price. Must not be negative. |
discountAmount | number | Required | Discount amount. |
discountPercent | number | Required | Discount percent. At most 100. |
finalPrice | number | Required | Price after discount. Must not be negative. |
units | number or string | Optional | Units the price covers. |
summary | object | Required if | Submitting. Fee totals. |
summary.totalOneTimeFee | number | Required | Total one-time fees. At most 2 decimal places. |
summary.totalMonthlyRecurringFee | number | Required | Total monthly recurring fees. At most 2 decimal places. |
summary.totalAnnuallyRecurringFee | number | Required | Total annual recurring fees. At most 2 decimal places. |
Sample request
The example is an Individual Dealer with one rooftop on Vini AI sales inbound and service inbound. All amounts are set to 0 as placeholders. Real amounts come from the pricing catalog and the deal. pricing is shortened: send the full data.pricing from the config call.
curl -X POST "https://api.spyne.ai/credit/v6/contracting/create-contract-v2" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"isDraft": false,
"contractId": "CONTRACT_ID",
"contractDetails": {
"enterpriseId": "ENTERPRISE_ID",
"currency": "USD",
"dealType": "New Deal",
"customerType": "Individual Dealer",
"managerName": ""
},
"customerDetails": {
"legalName": "Example Motors LLC",
"brandName": "Example Motors",
"region": "AMER",
"streetAddress": "100 Example Street",
"country": "United States",
"pincode": "00000",
"city": "Example City",
"state": "Example State"
},
"pricing": {
"conversationalAi": { "type": "<type>", "allowedProducts": {}, "items": {} }
},
"rooftops": [
{
"rooftopName": "Example Motors",
"subType": "Franchise Dealer",
"region": "AMER",
"streetAddress": "100 Example Street",
"country": "United States",
"pincode": "00000",
"city": "Example City",
"state": "Example State",
"bundle": "bundle-1",
"productsLines": [
{
"productLineName": "conversationalAi",
"plan": "",
"oneTimePurchases": [],
"products": [
{ "product": "salesInbound", "units": "100", "unitType": "vinsPerMonth", "cost": 0 },
{ "product": "serviceInbound", "units": "100", "unitType": "roPerMonth", "cost": 0 }
]
}
]
}
],
"signatoryDetails": {
"name": "Jane Doe",
"email": "[email protected]",
"designation": "General Manager",
"contactNumber": { "isdCode": "+1", "number": "5550100000" },
"clientPOCs": []
},
"billingDetails": {
"billingFrequency": 12,
"financePOCName": "John Doe",
"financePOCEmail": "[email protected]",
"financePOCNumber": { "isdCode": "+1", "number": "5550100000" }
},
"terms": {
"t&cTemplatePath": "contracts/default-terms-and-conditions-template.pdf",
"exception": false,
"contractTerm": 12,
"lockInPeriod": 12,
"terminationDays": 30,
"noticePeriodForAutoRenewal": 30,
"editT&C": false
},
"line_items": {
"type": "bundle",
"value": {
"bundle-1": {
"existingInventoryPerRooftop": 0,
"rooftopsCount": 1,
"studioAi": { "studio": [], "addOns": [] },
"conversationalAi": {
"salesAgent": [
{ "type": "Inbound", "frequency": "Monthly", "basePrice": 0, "discountAmount": 0, "discountPercent": 0, "finalPrice": 0, "units": 100 }
],
"serviceAgent": [
{ "type": "Inbound", "frequency": "Monthly", "basePrice": 0, "discountAmount": 0, "discountPercent": 0, "finalPrice": 0, "units": 100 }
],
"receptionAgent": [],
"acquisitionAgent": [],
"addOns": []
}
}
}
},
"summary": {
"totalOneTimeFee": 0,
"totalMonthlyRecurringFee": 0,
"totalAnnuallyRecurringFee": 0
}
}'import json
import requests
# contract.json holds the same body as the cURL example
with open("contract.json") as f:
payload = json.load(f)
res = requests.post(
"https://api.spyne.ai/credit/v6/contracting/create-contract-v2",
json=payload,
headers={"Authorization": "Bearer YOUR_TOKEN"},
)
print(res.status_code, res.json())
contract_id = res.json()["data"]["contractId"]import { readFile } from "node:fs/promises";
// contract.json holds the same body as the cURL example
const payload = JSON.parse(await readFile("contract.json", "utf8"));
const res = await fetch("https://api.spyne.ai/credit/v6/contracting/create-contract-v2", {
method: "POST",
headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await res.json();
console.log(res.status, body);
const contractId = body.data.contractId;To save a draft instead, send the same body with "isDraft": true. For a new draft, leave out contractId.
Sample response
{
"message": "Contract created successfully",
"error": false,
"code": "",
"details": null,
"data": {
"success": true,
"message": "Contract initiated successfully. Ready to send for signature.",
"contractId": "CONTRACT_ID",
"enterpriseId": "ENTERPRISE_ID",
"contractStage": "Contract-Initiated",
"note": "Child documents will be created after contract is fully signed"
}
}{
"message": "Contract created successfully",
"error": false,
"code": "",
"details": null,
"data": {
"success": true,
"isDraft": true,
"message": "Draft contract saved successfully",
"contractId": "CONTRACT_ID",
"enterpriseId": "ENTERPRISE_ID",
"contractStage": "Draft"
}
}{
"message": "Signatory email is required",
"error": true,
"code": "",
"details": null,
"data": {}
}{
"message": "Rooftop #1 \"Example Motors\":\n - bundle is required\n - Conversational AI → salesInbound: Unit type is required",
"error": true,
"code": "",
"details": null,
"data": {}
}{
"message": "Enterprise not found",
"error": true,
"code": "",
"details": null,
"data": { "enterpriseId": "ENTERPRISE_ID" }
}{ "message": "Invalid Token", "error": true, "code": "ERROR_AUTH_401", "details": null, "data": null }{
"message": "Contract not found with ID: CONTRACT_ID",
"error": true,
"code": "",
"details": null,
"data": {}
}Rooftop checks are grouped per rooftop in one message.
Response parameters
| Parameter | Type | Description |
|---|---|---|
data.success | boolean | true when saved. |
data.isDraft | boolean | Present and true for a draft save. |
data.message | string | Result message. |
data.contractId | string | Contract ID. Use it for later drafts, the PDF upload, and Send for signature. |
data.enterpriseId | string | Dealer group ID. |
data.contractStage | string | Draft or Contract-Initiated. |
data.note | string | Present on submit. It states that child records are created after signing. |
Errors
| Status | Message | When it happens |
|---|---|---|
400 | enterpriseId is required | contractDetails or contractDetails.enterpriseId is missing (draft or submit). |
400 | Field check message, for example Currency is required, Invalid region "X". Please select a valid region., line_items.value.bundle-1.conversationalAi.salesAgent[0].frequency is required and must be one of: ..., summary.totalOneTimeFee must have up to 2 decimal places | A submit fails a body check. The first failing check is returned, except rooftop and product checks, which are collected into one message per rooftop. |
400 | Individual Dealer can only have 1 rooftop | Individual Dealer with more than one rooftop. |
400 | Duplicate rooftop names found: ... | Two rooftops share a name. |
400 | Rooftop "NAME" already has an active contract (Contracted or Contract-Live). Please create an amendment instead. | A rooftop in the body is already under an active contract. |
400 | An active Individual Dealer contract (...) already exists for this enterprise. ... | The dealer group already has an active Individual Dealer contract. |
400 | Cannot change account type from Group dealer to Individual dealer when active contracts exist | Switching an active Group Dealer to Individual Dealer. |
400 | Currency must be INR when Country is India (got currency: ...) | Country is India and currency is not INR. |
400 | Invalid account manager "...". Must be one of: ... | managerName is set and not in the allowed list (draft or submit). |
400 | Contract CONTRACT_ID does not belong to enterprise ENTERPRISE_ID | contractId belongs to another dealer group. |
400 | Cannot modify contract CONTRACT_ID. Contract must be in 'Draft' stage. Current stage: ... | contractId is not a draft. |
400 | Enterprise not found | Submit, and the dealer group could not be read. |
401 | Invalid or missing token | No Bearer token, or the token failed validation. |
408 | Request timed out | The token validation call could not connect. |
500 | Contract not found with ID: CONTRACT_ID | contractId does not exist. This returns 500, not 404. |
500 | Other error message | For example, the default contract template is missing, or a database error occurs. |
What happens next
- The console builds the contract PDF and uploads it to the contract's file location, using an upload link from
POST /console/v1/util/gen-presigned-contractingwithcontractIdandenterpriseId. - If
terms.exceptionisfalse, call Send for signature withdata.contractId.
Draft saves can be repeated with the same contractId until the contract is submitted.
Called before: Get contract config, Reload a saved contract. Called after: Send for signature.
Updated about 1 hour ago
