Overview
You collect the business details. Innkorp prices the registration, takes the paid order server-to-server, files the CAC registration and creates a vault for the business owner. You settle what Innkorp is owed the next business day.
- Catalog. Read the business types and current prices, so nothing in your selection screen is hardcoded. Prices already include your contract adjustment.
- Hand off. After a successful payment on your checkout, post the paid order.
- Track. Receive webhooks as the order moves, then read the finished business from the vault.
https://api.innkorp.com. All requests and responses are JSON.Authentication
You get two keys from the Partner portal. Treat the secret key like a password and keep it on your server.
These endpoints accept server-to-server calls only. A call from a browser page is refused with 403 before anything else is checked, and no Access-Control-Allow-Origin header is returned to another origin. Both keys belong on your backend. A call from a browser means one of them has already reached your users. The only browser page allowed is the API explorer, which is served from this origin.
pk_live_…publishablerequiredSent in X-innKorp-Key on every call. Treat it as a credential and keep it server-side.
sk_live_…secretrequiredNever sent. It derives the cipher key for the endpoints that seal their payload. Keep it server-side.
A request made with a pk_test_… or sk_test_… key runs against the Innkorp sandbox, a separate database with test data, so nothing touches production. Endpoints and payloads are the same; only the key prefix differs.
Generate your sandbox keys from the API Keys tab in the Partner portal. Like the live secret, the test secret is shown once when it is generated, so save it then. Regenerating reveals a new one and retires the old.
// Every call carries your API key. The secret is never sent.
const res = await fetch("https://api.innkorp.com/api/v1/pricing/catalog", {
headers: { "X-innKorp-Key": API_KEY }
});Encrypting payloads
Three endpoints take a sealed body instead of plain JSON: POST /api/v1/registrations, POST /api/v1/document-requests and GET /api/v1/vault/{business_identifier}. You seal the request; we seal the response.
v1.<iv>.<tag>.<ciphertext>AES-256-GCM. Cipher key is SHA-256(secret_key). A fresh 12-byte IV per request and the 16-byte auth tag, each base64url, joined with dots after the version prefix.
X-innKorp-KeystringrequiredYour API key (pk_live_… or pk_test_…). Sent on every call. Keep it server-side.
encryptedstringrequiredThe sealed envelope, and the only key in the request body.
time_stampstringrequiredISO timestamp, placed inside the payload before sealing. Requests more than five minutes old are rejected. Innkorp adds the same field to the payloads it seals back to you.
import crypto from "crypto";
function deriveKey(secretKey) {
return crypto.createHash("sha256").update(secretKey).digest();
}
function seal(payload, secretKey) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", deriveKey(secretKey), iv);
// Required. Rejected beyond five minutes.
const body = JSON.stringify({
...payload,
time_stamp: new Date().toISOString()
});
const ct = Buffer.concat([cipher.update(body, "utf8"), cipher.final()]);
return [
"v1",
iv.toString("base64url"),
cipher.getAuthTag().toString("base64url"),
ct.toString("base64url")
].join(".");
}
const res = await fetch("https://api.innkorp.com/api/v1/registrations", {
method: "POST",
headers: {
"X-innKorp-Key": API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ encrypted: seal(registration, SECRET_KEY) })
});Catalog
/api/v1/pricing/catalogLists every business type that can be registered, each with its price under your contract. The business_type value is what you send when you hand off the order. A business type, plus a share capital tier where one is needed, identifies a price. The price is on the item itself, so no separate lookup is needed.
Types where needs_share_capital is true carry a prices array instead of a single price, with one entry per tier in ascending order of share_capital. That array is the full tier list for the type. Every option listed has a price, so you never need to handle a missing one.
Each price is split in two: partners_share is your margin under your contract and innkorp_share is Innkorp's part. The two always add up to price, which is the amount you charge and hand off.
services lists the documents you can order about a business that is already registered, priced the same way. Its value is the document_type you send when you order a document. The price comes from your agreement for that service only. Your general contract does not apply, so if you have no agreement for the service you pay the base price and take no share. A service that has not been priced yet does not appear in the array.
X-innKorp-KeystringrequiredYour API key (pk_live_… or pk_test_…). Sent on every call. Keep it server-side.
const res = await fetch("https://api.innkorp.com/api/v1/pricing/catalog", {
headers: { "X-innKorp-Key": API_KEY }
});
const { business_types } = await res.json();
// No share capital needed:
// businessType.price
// Needs share capital:
// businessType.prices.find(
// p => p.share_capital === shareCapital
// ).price{
"business_types": [
{
"value": "partnership",
"label": "Partnership",
"needs_share_capital": false,
"price": 45000,
"partners_share": 5000,
"innkorp_share": 40000,
"prices": null
},
{
"value": "private_company_limited_by_shares",
"label": "Private Company Limited By Shares",
"needs_share_capital": true,
"price": null,
"partners_share": null,
"innkorp_share": null,
"prices": [
{
"share_capital": 1000000,
"price": 55000,
"partners_share": 5000,
"innkorp_share": 50000
},
{
"share_capital": 2000000,
"price": 85000,
"partners_share": 5000,
"innkorp_share": 80000
},
{
"share_capital": 5000000,
"price": 175000,
"partners_share": 5000,
"innkorp_share": 170000
}
]
}
],
"services": [
{
"value": "cac_status_report",
"label": "CAC status report",
"price": 15000,
"partners_share": 0,
"innkorp_share": 15000,
"prices": null
}
]
}Hand off the order
/api/v1/registrationsCall this from your server after a successful payment. The request is rejected unless payment.status is success, the amount matches the price the catalog returned for that business type, and an NDPR consent attestation is present.
You collect the payment yourself. That is currently the only supported option, so payment is always required. Handing the order off without payment and letting Innkorp collect from the customer is not available yet.
The consent object is an attestation. Innkorp cannot verify that consent was given, only that you stated it was. What you send is stored unchanged on the order and is the whole NDPR audit trail for that registration. For that reason obtained, time_stamp and channel are required, and you should send notice_version if you version your privacy notices. A record that cannot show when consent was taken will not satisfy a regulator later.
This request carries personal data, so the body is sealed. See Encrypting payloads.
X-innKorp-KeystringrequiredYour API key (pk_live_… or pk_test_…). Sent on every call. Keep it server-side.
Content-TypestringrequiredAlways application/json.
business_identifierstringrequiredYour own ID for this business. It is returned on the response, on every webhook and when you read the business, so you do not need a table that maps your IDs to ours. It is also the key you use to look the business up later. It identifies a business, not a customer: a customer who hands off three businesses gets three different values. You must not have used it before, or the call is rejected with 409. Because it becomes part of the URL you read the business from, it must be 128 characters or fewer, contain no whitespace or any of / ? # %, and not be pull or documents. Anything else is rejected with 400.
business_typestringrequiredThe business type value from the catalog, for example partnership. Innkorp works out the classification from it.
share_capitalnumberoptionalRequired only for a company limited by shares.
proposed_business_namestringrequiredThe name the customer wants to register. Use letters, numbers and spaces only. Punctuation and accented characters are rejected with 400, because CAC does not accept them. Between 2 and 200 characters. Send one name: Innkorp's own form collects alternatives, but this API takes a single name so you do not have to ask your customer for backups.
contact_first_namestringrequiredFirst name of the person Innkorp contacts about this registration.
contact_last_namestringrequiredLast name of that contact.
contact_phone_numberstringrequiredThe contact's phone number. It also identifies the owner: it is the number they later sign in to Innkorp with, and how a returning customer is matched. Either this or contact_email is required.
contact_emailstringrequiredThe contact's email address, the other way the owner is identified. Either this or contact_phone_number is required.
payment.statusstringrequiredMust be "success" or the call is rejected with 402.
payment.referencestringrequiredYour payment reference, and the idempotency key. Sending the same one twice returns the original registration instead of creating a second.
payment.amountnumberrequiredMust match the catalog price, or the call is rejected with 409.
payment.currencystringrequiredCurrently only "NGN".
consent.obtainedbooleanrequiredMust be true or the call is rejected with 400.
consent.time_stampstringrequiredISO 8601 timestamp of when the NDPR consent was obtained. This is separate from the envelope's own time_stamp, which protects against replays. Rejected with 400 if missing or unreadable.
consent.notice_versionstringoptionalVersion of the privacy notice shown when consent was given. Send it if you version your notices. It is stored as sent.
consent.channelstringrequiredWhere consent was captured, for example "your_app". Rejected with 400 if missing.
// seal() is defined under "Encrypting payloads".
const registration = {
business_identifier: "biz_8812", // YOUR id for this business
business_type: "partnership",
share_capital: null,
proposed_business_name: "Adunni Foods",
contact_first_name: "Adunni",
contact_last_name: "Okafor",
contact_phone_number: "+2348012345678",
contact_email: "adunni@example.com",
payment: {
status: "success",
reference: "PAY-123",
amount: businessType.price,
currency: "NGN"
},
consent: {
obtained: true,
time_stamp: new Date().toISOString(),
notice_version: "2026-06-01",
channel: "your_app"
}
};
const res = await fetch("https://api.innkorp.com/api/v1/registrations", {
method: "POST",
headers: {
"X-innKorp-Key": API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ encrypted: seal(registration, SECRET_KEY) })
});{
"business_identifier": "biz_8812",
"registration_id": "8f3a1c2b-9d4e-4f1a-b2c3-000000000000",
"sme_id": "a1b2c3d4-5e6f-7a8b-9c0d-000000000000",
"status": "received",
"innkorp_share": 45000,
"settlement_due_at": "2026-08-06T00:00:00.000Z"
}Order a document
/api/v1/document-requestsOrders a new copy of a document about a business that is already registered. The CAC status report is currently the only document available. Call this from your server after a successful payment. As with a registration, the request is rejected unless payment.status is success, the amount matches the price the catalog returned under services, and an NDPR consent attestation is present.
Innkorp never contacts the business, so the request has no contact fields. We get the report from the CAC portal. If we already hold a recent verified report for the same registration number and name, it is delivered within seconds instead. The webhooks are the same in both cases.
Unlike a hand-off, a business_identifier can repeat, so you can order a report for the same business as often as you need. payment.reference is still the idempotency key: sending the same one again returns the original order with a 200.
When documents_ready fires, collect the report with GET /api/v1/vault/{business_identifier}. How recent the report is depends on its report_generated_at, not on the date you ordered it.
Sandbox. With a test key, three registration numbers complete on their own within a minute or two, so you can test every webhook without waiting for us: RC9000001 ends in documents_ready with a sample PDF, RC9000002 in failed (rc_not_found), and RC9000003 in failed (entity_not_active). Use entity_type: "company". Live keys never use these test numbers.
This request carries personal data, so the body is sealed. See Encrypting payloads.
X-innKorp-KeystringrequiredYour API key (pk_live_… or pk_test_…). Sent on every call. Keep it server-side.
Content-TypestringrequiredAlways application/json.
business_identifierstringrequiredYour own ID for this business. It is returned on the response and on every webhook, and it can repeat across document requests. The same format rules apply as on a hand-off, and a value that breaks them is rejected with 400.
document_typestringrequiredA service value from the catalog. Currently only "cac_status_report". Unknown values are rejected with 400.
business_registration_numberstringrequiredThe CAC number (RC, BN, IT, LP or LLP), with or without the prefix. Spaces, dots, dashes and "No." are accepted, so "RC 1682093" becomes RC1682093. A number without a prefix takes it from entity_type. At most 8 digits. A number that cannot be read is rejected with 422 and a reason.
registered_business_namestringrequiredThe name the business is registered under at CAC. Rejected with 400 if missing.
entity_typestringrequiredOne of business_name, company, incorporated_trustee, limited_partnership, limited_liability_partnership, cooperative. Cooperatives have no CAC prefix, so a bare number with cooperative is rejected.
payment.statusstringrequiredMust be "success" or the call is rejected with 402.
payment.referencestringrequiredYour payment reference, and the idempotency key. A reference already used for a different kind of order is rejected with 409.
payment.amountnumberrequiredMust match the service price in the catalog, or the call is rejected with 409.
payment.currencystringrequiredCurrently only "NGN".
consentobjectrequiredThe same NDPR attestation as on a hand-off: obtained, time_stamp, notice_version (optional) and channel.
// seal() is defined under "Encrypting payloads".
const order = {
business_identifier: "biz_8812", // may repeat
document_type: "cac_status_report",
business_registration_number: "RC 1682093",
registered_business_name: "PORCHPLUS LIMITED",
entity_type: "company",
payment: {
status: "success",
reference: "pvb_ref_001",
amount: service.price,
currency: "NGN"
},
consent: {
obtained: true,
time_stamp: new Date().toISOString(),
notice_version: "v3",
channel: "app"
}
};
const res = await fetch("https://api.innkorp.com/api/v1/document-requests", {
method: "POST",
headers: {
"X-innKorp-Key": API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ encrypted: seal(order, SECRET_KEY) })
});{
"request_id": "5c7e2a1d-3b4f-4e6a-9d8c-000000000000",
"business_identifier": "biz_8812",
"status": "received",
"innkorp_share": 15000,
"settlement_due_at": "2026-10-06T00:00:00.000Z"
}Pull business details
/api/v1/vault/{business_identifier}Returns everything Innkorp holds for a registered business: its details, directors, shareholders and documents. You address it by the business_identifier you sent at handoff, so you never need to store an ID of ours.
It also includes details Innkorp found while processing that you did not send: business_registration_number, tin, and the registered category and subcategory.
Until status is documents_ready, only status is returned. The business, directors, shareholders and documents fields are all null, because nothing has been filed yet. Wait for the registration.documents_ready webhook instead of polling.
Your first successful pull completes the registration. It closes the case on our side and triggers the message telling the customer their KYC is ready. The message is sent at that point because you then hold the documents. Later pulls send nothing; the customer is told once. No registration.completed webhook follows.
After a document_request.documents_ready webhook, the newest verified cac_status_report is in documents with its request_id, report_generated_at, cac_payment_reference (the Remita RRR) and company_status. Its expiry_date is report_generated_at plus 6 months.
The response returns personal data, so it comes back sealed. See Encrypting payloads.
You only receive the fields you asked for. Your integration declares which fields it needs, and anything else is null. A section you have no access to, such as shareholders, is null rather than an empty list. This is data minimisation, not an error. Ask Innkorp to widen your access if you need more.
X-innKorp-KeystringrequiredYour API key (pk_live_… or pk_test_…). Sent on every call. Keep it server-side.
business_identifierstringrequiredYour own ID for the business, as sent when you handed it off. It goes in the URL path, not the body.
const res = await fetch(
"https://api.innkorp.com/api/v1/vault/" + business_identifier,
{ headers: { "X-innKorp-Key": PUBLIC_KEY } }
);
// open() is defined under "Encrypting payloads".
const { encrypted } = await res.json();
const business = open(encrypted, SECRET_KEY);
if (business.status !== "documents_ready") {
// Not filed yet: everything except status is null. Wait for the webhook.
}{
"status": "documents_ready",
"business": {
"tin": "12345678-0001",
"business_registration_number": "RC1234567",
"registered_business_name": "Adunni Foods",
"registered_business_type": "partnership",
"registered_business_category": "Accommodation and Food Services",
"registered_business_subcategory": "Operate restaurant and catering services",
"city": "Lagos",
"state": "Lagos",
"country": "Nigeria",
"registered_business_address": "12 Marina Rd, Lagos"
},
"directors": [
{
"first_name": "Adunni",
"last_name": "Okafor",
"date_of_birth": "1990-04-12",
"gender": "Female",
"phone_number": "+2348012345678",
"occupation": "Product Manager",
"email_address": "adunni@example.com",
"nationality": "Nigerian",
"residential_address": "5 Allen Avenue, Lagos",
"id_type": "National Identification Number",
"id_number": "034322323",
"is_signatory": true,
"is_shareholder": true
}
],
"shareholders": [
{
"first_name": "Adunni",
"last_name": "Okafor",
"percentage_shareholding": 100,
"is_beneficial_owner": true
}
],
"documents": [
{
"doc_type": "cac_certificate",
"file_name": "cac-certificate.pdf",
"signed_url": "https://…/signed?token=…",
"expiry_date": null,
"uploaded_at": "2026-06-16T10:00:00.000Z"
},
{
"doc_type": "cac_status_report",
"file_name": "cac-status-report.pdf",
"signed_url": "https://…/signed?token=…",
"request_id": "5c7e2a1d-3b4f-4e6a-9d8c-000000000000",
"report_generated_at": "2026-10-05T11:20:00.000Z",
"cac_payment_reference": "310012345678",
"company_status": "ACTIVE",
"expiry_date": "2027-04-05T11:20:00.000Z",
"uploaded_at": "2026-10-05T11:25:00.000Z"
}
]
}// Before the filing is done:
{
"status": "processing",
"business": null,
"directors": null,
"shareholders": null,
"documents": null
}Webhook events
Innkorp sends a POST request with a JSON body to your webhook URL, signed with HMAC-SHA256 in the X-innKorp-Signature header. The list below says what triggers each event.
The signature uses your webhook signing key, found on the API keys page of the partner portal. It is not your secret key, and no other partner shares it. There is one per environment: your test key signs sandbox events and your live key signs live ones. A rotated key is used from the next event onwards, with no period where both keys work.
Every event carries your own business_identifier in data whenever we can match one to the business, so you can route a delivery to your record without a mapping table. It is missing only for a business handed off before you started sending one. In that case, use sme_id.
Every event about a business you handed off also carries its name, in two fields. The proposed_business_name field is the name you sent on handoff. The approved_business_name field is the name Innkorp approved for filing with CAC. It is null until then, and it can differ from the proposal if CAC turned that down. Show the approved name once you have it.
registration.receivedYour hand-off succeeded and the paid order was created.
registration.processingInnkorp started processing the CAC registration.
registration.documents_readyThe documents issued by CAC are in the vault. Pull them with GET /api/v1/vault/{business_identifier}. That pull completes the registration, so this is the last event you need to act on.
registration.completedDeprecated and no longer sent. Your pull after documents_ready completes the registration.
document_request.receivedYour document order succeeded and the paid order was created. Also includes registered_business_name, business_registration_number, amount_paid, innkorp_share and settlement_due_at.
document_request.processingInnkorp started getting the document.
document_request.documents_readyThe document is in the vault, with its report_generated_at. Pull it with GET /api/v1/vault/{business_identifier}. If the request was met from a report Innkorp already held, received, processing and this event arrive in order within a minute or two.
document_request.failedInnkorp could not get the document. failure_reason is rc_not_found, name_mismatch or entity_not_active.
settlement.settledInnkorp confirmed it received your settlement payment.
POST {your webhook_url}
X-innKorp-Signature: <hex HMAC-SHA256 of the raw body>
{
"event": "registration.documents_ready",
"time_stamp": "2026-06-18T10:00:00.000Z",
"data": {
"business_identifier": "biz_8812",
"proposed_business_name": "Adaeze Foods",
"approved_business_name": "Adaeze Foods",
"status": "documents_ready"
}
}import crypto from "crypto";
function verify(rawBody, signatureHeader, webhookSecret) {
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}Document types
doc_type is one of the following. A business may not have every type.
cac_certificatecac_status_reportmemarttin_certificateutility_billboard_resolutionscuml_certificateubo_declarationoperating_licensedirector_idotherErrors
Errors return { "error": "message" } with a standard HTTP status.
400Bad request. A required field is missing, or a sealed payload could not be opened or has expired.401Missing or invalid API key.402Payment is not marked successful, so the order cannot be created.403Called from a browser page, your account is disabled or not configured for API registrations, or a live key was used before your account was approved for production.404The registration or business was not found.409The amount paid does not match the catalog price, that business_identifier has already been handed off, the service is not yet available, or payment.reference was already used for a different kind of order.422A document request's business_registration_number does not parse. The body adds a reason: empty, invalid_characters, unknown_prefix, too_long or prefix_entity_mismatch.500An error on our side. You can safely retry.Resources
- API explorer · try the endpoints in your browser. Only the catalog can run there, because the three sealed endpoints need your secret key, which must never reach a browser.
- Download the OpenAPI spec · import it into your own tooling to generate a client.
- Download the Postman collection · every endpoint as a Postman request.
- Partner portal · get your API keys, set your webhook URL, and view your account-specific integration guide.
- Registration pricing · what Innkorp charges before your contract adjustment.
