# Getting Started

Xmltronik API provides product catalog access through the partner API. Your partner account is ready to use after activation.

## Base URL

| Environment | Base URL |
| --- | --- |
| Production | https://openapi.xmltronik.id |

## Prerequisite: OpenAPI registration

Before a partner account is created, submit an OpenAPI registration to XMLTRONIK through our team. This registration is required so partner API access can be prepared. After the registration is approved, XMLTRONIK creates the partner account and sends an activation email to the address you registered.

## 1. Activate your account

Open the activation link sent to your partner email. The link is valid for 24 hours. Set your password, then sign in at `/partner`.

## 2. Set up your credentials

In the partner dashboard, generate a partner key pair. Keep the private key on your side and upload the public key to the platform. Fill in the callback URL and IP allowlist as needed.

The dashboard also shows the platform public key for callback verification. The client secret is available on the same page and can be revealed again or rotated.

Key pair generation commands and format details are in [Signature Generation](/docs/signature).

## 3. Request an access token

```
POST /openapi/v1.0/access-token/b2b
X-CLIENT-KEY: <client_id>
X-TIMESTAMP: <ISO-8601>
X-SIGNATURE: <SHA256withRSA(clientId|X-TIMESTAMP)>

{ "grantType": "client_credentials" }
```

The signature is made with the partner private key. The access token is valid for at most 15 minutes. Full steps, example values, and error handling are in [Signature Generation](/docs/signature).

## 4. Fetch the product catalog

```
GET /openapi/v1.0/products
Authorization: Bearer <accessToken>
X-PARTNER-ID: <client_id>
X-EXTERNAL-ID: <random string>
CHANNEL-ID: WEB
X-TIMESTAMP: <ISO-8601>
X-SIGNATURE: HMAC_SHA512(METHOD:path:token:sha256(body):timestamp)
```

The response contains the product catalog available to your partner account, grouped by operator.

---

# Signature Generation

Every partner request uses two different signatures. The first signature requests an access token and uses the partner private key. The second signature calls a product endpoint and uses the client secret. Both use hex encoding, and all strings are signed as UTF-8.

## Summary

| Stage | String to sign | Key or secret | Header |
| --- | --- | --- | --- |
| Access token | clientId\|timestamp | Partner private key | X-SIGNATURE |
| API request | METHOD:path:accessToken:sha256(body):timestamp | Client secret | X-SIGNATURE |

## What you need

- `client_id` from the partner dashboard.
- `client_secret` from the partner dashboard. Use the exact value, do not decode it.
- The partner private key you generated yourself. Never send it to the platform.
- The partner public key uploaded to the dashboard.
- `CHANNEL-ID` from the partner dashboard. The current value is `WEB`.

## Generate a partner key pair

Generate an RSA 2048-bit key pair on your side. Keep the private key, upload the public key to the partner dashboard.

```
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out partner-private-key.pem
openssl rsa -pubout -in partner-private-key.pem -out partner-public-key.pem
```

Upload the contents of `partner-public-key.pem`. The private key file must not be uploaded. The platform uses the public key to verify the access token signature.

## 1. Create a timestamp

Create the timestamp at request time, not when the application starts. Use`yyyy-MM-ddTHH:mm:ss+07:00`, without milliseconds. The value must be within 5 minutes of server time.

```
function timestampNow() {
  return new Date(Date.now() + 7 * 60 * 60 * 1000)
    .toISOString()
    .replace(/\.\d{3}Z$/, "+07:00");
}

// Example: 2026-11-04T14:30:00+07:00
const timestamp = timestampNow();
```

The access token timestamp and the request timestamp may differ. As long as each one is within the 5-minute tolerance, both are valid.

Make sure the server clock uses time synchronization (NTP). A clock difference of more than 5 minutes causes the timestamp to be rejected.

## 2. Create the access token signature

Build the following string with no extra spaces:

```
clientId + "|" + timestamp
```

Example values:

| Part | Example |
| --- | --- |
| clientId | 01a1b2c3-d4e5-f6a7-b8c9-d0e1f2a3b4c5 |
| timestamp | 2026-11-04T14:30:00+07:00 |
| String to sign | 01a1b2c3-d4e5-f6a7-b8c9-d0e1f2a3b4c5\|2026-11-04T14:30:00+07:00 |

Sign that string with the partner private key using RSA-SHA256 with PKCS#1 v1.5 padding. The result is lowercase hex. For RSA 2048, the signature is 512 hex characters long.

```
import { createSign } from "node:crypto";

const stringToSign = `${clientId}|${timestamp}`;
const tokenSignature = createSign("RSA-SHA256")
  .update(stringToSign, "utf8")
  .sign(privateKeyPem, "hex");
```

The access token body is not part of this signature. It only uses `clientId` and `timestamp`.

Send the following request. The body must be valid JSON.

```
POST /openapi/v1.0/access-token/b2b
Content-Type: application/json
X-CLIENT-KEY: <client_id>
X-TIMESTAMP: <timestamp>
X-SIGNATURE: <tokenSignature>

{ "grantType": "client_credentials" }
```

A successful response contains the access token and its lifetime:

```
{
  "responseCode": "2000100",
  "responseMessage": "Successful",
  "accessToken": "<accessToken>",
  "tokenType": "Bearer",
  "expiresIn": "900",
  "additionalInfo": {}
}
```

Store `accessToken`. It is valid for at most 15 minutes. Do not reuse an expired token and never send the private key to the platform.

## 3. Prepare the request signature inputs

The request signature uses five values:

| Value | Rule | Example |
| --- | --- | --- |
| METHOD | Uppercase | GET |
| path | Path and query, without scheme and host | /openapi/v1.0/products |
| accessToken | Token only, without the Bearer prefix | <accessToken> |
| bodyHash | SHA-256 of the body, lowercase hex | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
| timestamp | Request timestamp | 2026-11-04T14:30:00+07:00 |

For GET, the body is an empty string. The SHA-256 of an empty string is always`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.

For POST, use the raw body that is actually sent. Do not change key order, spaces, or formatting after the signature is created. When in doubt, hash the same buffer that is sent to the HTTP client.

```
import { createHash } from "node:crypto";

const method = "GET";
const path = "/openapi/v1.0/products";
const body = "";
const bodyHash = createHash("sha256").update(body, "utf8").digest("hex");
```

## 4. Create the request signature

Join the values with colons, without spaces:

```
METHOD:path:accessToken:bodyHash:timestamp
```

Full example string for GET:

```
GET:/openapi/v1.0/products:<accessToken>:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:2026-11-04T14:30:00+07:00
```

Compute HMAC-SHA512 with `client_secret` exactly as shown in the dashboard. Do not base64-decode the client secret. The result is lowercase hex.

```
import { createHmac } from "node:crypto";

const stringToSign = `${method}:${path}:${accessToken}:${bodyHash}:${timestamp}`;
const requestSignature = createHmac("sha512", clientSecret)
  .update(stringToSign, "utf8")
  .digest("hex");
```

If the path has a query string, include it. Example:`/openapi/v1.0/products?example=1`. Do not use the full URL.

## HMAC test vector

Use the following values to verify your HMAC implementation. This client secret is only an example and does not work on the platform.

| Value | Example |
| --- | --- |
| client_secret | contoh-client-secret |
| accessToken | token-contoh |
| method | GET |
| path | /openapi/v1.0/products |
| bodyHash | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
| timestamp | 2026-11-04T14:30:00+07:00 |

String to sign:

```
GET:/openapi/v1.0/products:token-contoh:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:2026-11-04T14:30:00+07:00
```

Expected HMAC-SHA512 result:

```
1e343cec039c43ece0058a04db425f7ae411b81635e1418cf6052f7d3fd58f26210a9798166f6d2d62e0431986f6a2eb1373e292d6a6f6b69454491879303f78
```

If your result differs, check the order of the parts, the number of colons, the secret encoding, and make sure there are no extra spaces.

## 5. Send the request

| Header | Value | Note |
| --- | --- | --- |
| Authorization | Bearer <accessToken> | The same token used in the signature |
| X-PARTNER-ID | <client_id> | Must match the client in the access token |
| X-EXTERNAL-ID | Unique string, max 36 characters | Random, unique per request. Allowed: letters, digits, dot, underscore, hyphen |
| CHANNEL-ID | WEB | Must match the partner channel |
| X-TIMESTAMP | <timestamp> | The timestamp used in the signature |
| X-SIGNATURE | <requestSignature> | Lowercase hex |

```
GET /openapi/v1.0/products
Authorization: Bearer <accessToken>
X-PARTNER-ID: <client_id>
X-EXTERNAL-ID: 7f3c2a1b-9d4e-4c6a-8b2f-1e5d7c9a0b3f
CHANNEL-ID: WEB
X-TIMESTAMP: 2026-11-04T14:30:00+07:00
X-SIGNATURE: <requestSignature>
```

## 6. Full example

The following Node.js example requests an access token, creates the request signature, and fetches the product catalog.

```
import { createHash, createHmac, createSign, randomUUID } from "node:crypto";

function timestampNow() {
  return new Date(Date.now() + 7 * 60 * 60 * 1000)
    .toISOString()
    .replace(/\.\d{3}Z$/, "+07:00");
}

const baseUrl = "https://api.example.com";
const clientId = "<client_id>";
const clientSecret = "<client_secret>";
const channelId = "WEB";
const privateKeyPem = process.env.PARTNER_PRIVATE_KEY;
if (!privateKeyPem) {
  throw new Error("PARTNER_PRIVATE_KEY is not set");
}

async function fetchProducts() {
  const tokenTimestamp = timestampNow();
  const tokenString = `${clientId}|${tokenTimestamp}`;
  const tokenSignature = createSign("RSA-SHA256")
    .update(tokenString, "utf8")
    .sign(privateKeyPem, "hex");

  const tokenResponse = await fetch(`${baseUrl}/openapi/v1.0/access-token/b2b`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CLIENT-KEY": clientId,
      "X-TIMESTAMP": tokenTimestamp,
      "X-SIGNATURE": tokenSignature,
    },
    body: JSON.stringify({ grantType: "client_credentials" }),
  });
  const tokenBody = await tokenResponse.json();
  if (!tokenResponse.ok) {
    throw new Error(
      "Token request failed: " + tokenResponse.status + " " + JSON.stringify(tokenBody),
    );
  }
  const accessToken = tokenBody.accessToken;

  const method = "GET";
  const path = "/openapi/v1.0/products";
  const body = "";
  const requestTimestamp = timestampNow();
  const bodyHash = createHash("sha256").update(body, "utf8").digest("hex");
  const requestString = `${method}:${path}:${accessToken}:${bodyHash}:${requestTimestamp}`;
  const requestSignature = createHmac("sha512", clientSecret)
    .update(requestString, "utf8")
    .digest("hex");

  const response = await fetch(`${baseUrl}${path}`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "X-PARTNER-ID": clientId,
      "X-EXTERNAL-ID": randomUUID(),
      "CHANNEL-ID": channelId,
      "X-TIMESTAMP": requestTimestamp,
      "X-SIGNATURE": requestSignature,
    },
  });
  const responseBody = await response.json();
  if (!response.ok) {
    throw new Error(
      "Product request failed: " + response.status + " " + JSON.stringify(responseBody),
    );
  }
  return responseBody;
}
```

## 7. Token expiry

The access token is valid for at most 15 minutes. If an endpoint returns`4010201`, request a new access token, then create a new request signature with the new token and timestamp. Do not reuse the old signature.

A simple approach: store `expiresIn`, refresh the token before it expires, and add one retry when you receive `4010201`.

Do not request a new access token for every request. Store the token and reuse it until it is close to expiry.

## How the signature is verified

The platform rebuilds the same string from the values it receives: client ID and timestamp for the access token, then method, path, access token, body hash, and timestamp for the request. The platform compares its result with `X-SIGNATURE`. Because of this, one extra space, a different part order, or a body that changes after signing makes the signature invalid.

## Common response codes

| Response code | Meaning | What to check |
| --- | --- | --- |
| 4000100 | Access token body is not valid JSON | The body must be valid JSON |
| 4000101 | Invalid access token field format | Timestamp +07:00, 5-minute tolerance, or grantType |
| 4000102 | Missing access token header | X-CLIENT-KEY, X-TIMESTAMP, X-SIGNATURE |
| 4010100 | Access token signature failed | Private key, client_id, and signature encoding |
| 4030100 | IP is not allowed | Partner IP allowlist |
| 4000201 | Invalid request field format | X-EXTERNAL-ID, X-TIMESTAMP, CHANNEL-ID |
| 4000202 | Missing request header | All required headers were sent |
| 4010200 | Request signature failed | Client secret, path, body hash, and timestamp |
| 4010201 | Invalid access token | Token expired or belongs to another partner |
| 4010203 | Token not found | Authorization Bearer header |
| 4030200 | Catalog access denied | IP allowlist and partner account status |

## Error response example

```
{
  "responseCode": "4010200",
  "responseMessage": "Unauthorized. Verify signature failed"
}
```

## Checklist before contacting support

- The timestamp uses the correct format and is within 5 minutes.
- The string to sign has no extra spaces.
- The access token signature uses the private key, not the client secret.
- The request signature uses the client secret, not the private key.
- The client secret is used exactly as shown, without base64 decoding.
- The path has no host and includes the query string when present.
- The body hash is computed from the body that is actually sent.
- The token in the signature matches the token in the Authorization header.
- CHANNEL-ID matches the partner channel.
- X-EXTERNAL-ID is unique and at most 36 characters.

---

# Access Token B2B

This endpoint issues the access token used by every partner request.

## Service Info

| Item | Value |
| --- | --- |
| HTTP Method | POST |
| Service Code | 01 |
| Base URL | URL provided at activation |
| Path | /openapi/v1.0/access-token/b2b |

## Header

| Header | Type | Required | Description |
| --- | --- | --- | --- |
| Content-Type | String | Yes | application/json |
| X-CLIENT-KEY | String | Yes | Partner client_id |
| X-TIMESTAMP | String | Yes | yyyy-MM-ddTHH:mm:ss+07:00 |
| X-SIGNATURE | String | Yes | RSA-SHA256(clientId\|X-TIMESTAMP) |

## Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| grantType | String | Yes | Value: client_credentials |

## Request Sample

```
{
  "grantType": "client_credentials"
}
```

## Response Structure

| Parameter | Type | Description |
| --- | --- | --- |
| responseCode | String | Response code |
| responseMessage | String | Response message |
| accessToken | String | Token for every partner request |
| tokenType | String | Bearer |
| expiresIn | String | 900 seconds |
| additionalInfo | Object | Additional information, currently empty |

## Response Sample

```
{
  "responseCode": "2000100",
  "responseMessage": "Successful",
  "accessToken": "<accessToken>",
  "tokenType": "Bearer",
  "expiresIn": "900",
  "additionalInfo": {}
}
```

## Response Codes

| Response Code | Message | Description |
| --- | --- | --- |
| 2000100 | Successful | Token issued |
| 4000100 | Bad Request | Invalid request body |
| 4000101 | Invalid Field Format | Invalid field format |
| 4000102 | Invalid Mandatory Field | Missing required header |
| 4010100 | Unauthorized | Invalid signature or unknown client |
| 4030100 | Forbidden | Access denied |
| 4290100 | Too Many Requests | Request limit exceeded |
| 5000101 | Internal Server Error | Server error |

Signature generation is documented in [Signature Generation](/docs/signature).

---

# Product Catalog

This endpoint returns the product catalog available to your partner account.

## Service Info

| Item | Value |
| --- | --- |
| HTTP Method | GET |
| Service Code | 02 |
| Base URL | URL provided at activation |
| Path | /openapi/v1.0/products |

## Header

| Header | Type | Required | Description |
| --- | --- | --- | --- |
| Authorization | String | Yes | Bearer <accessToken> |
| X-PARTNER-ID | String | Yes | Partner client_id |
| X-EXTERNAL-ID | String | Yes | Random string, max 36 characters |
| CHANNEL-ID | String | Yes | Value: WEB |
| X-TIMESTAMP | String | Yes | yyyy-MM-ddTHH:mm:ss+07:00 |
| X-SIGNATURE | String | Yes | HMAC-SHA512 request signature |

## Response Structure

| Parameter | Type | Description |
| --- | --- | --- |
| responseCode | String | Response code |
| responseMessage | String | Response message |
| data | Array | Product category list |
| data[].nama_kategori | String | Operator name |
| data[].produk | Array | Products in the category |
| data[].produk[].kode_produk | String | Product code |
| data[].produk[].nama_produk | String | Product name |
| data[].produk[].harga_jual | Number | Product price |
| data[].produk[].gangguan | Boolean | Product disruption status |

## Response Sample

```
{
  "responseCode": "2000200",
  "responseMessage": "Successful",
  "data": [
    {
      "nama_kategori": "TELKOMSEL",
      "produk": [
        {
          "kode_produk": "TSEL10",
          "nama_produk": "Telkomsel 10",
          "harga_jual": 10500,
          "gangguan": false
        }
      ]
    }
  ]
}
```

## Response Codes

| Response Code | Message | Description |
| --- | --- | --- |
| 2000200 | Successful | Catalog retrieved |
| 4000201 | Invalid Field Format | Invalid field format |
| 4000202 | Invalid Mandatory Field | Missing required header |
| 4010200 | Unauthorized | Request signature does not match |
| 4010201 | Invalid Token | Invalid access token |
| 4010203 | Token Not Found | Missing access token |
| 4030200 | Forbidden | Access denied |
| 4290200 | Too Many Requests | Request limit exceeded |
| 5000200 | General Error | Server error |

---

# Callbacks

The callback URL is configured per partner in the partner dashboard, not globally per account.

Callback delivery is not active yet. Planned headers: `X-CLIENT-ID`, `X-TIMESTAMP`, and `X-SIGNATURE`.

---

# Error Reference

| Status | Meaning |
| --- | --- |
| 400 Bad Request | Invalid request format or signature |
| 401 Unauthorized | Missing, invalid, or expired access token |
| 403 Forbidden | Credential revoked or signature mismatch |
| 429 Too Many Requests | Partner rate limit exceeded, see the Retry-After header |
| 500 Internal Server Error | Unexpected server error |

## Rate limiting

Partner endpoints have a per-partner rate limit. Exceeding the limit returns `429` with a `Retry-After` header.
