# Tokenization Service

Exchange a card number for a TOKEN_GUID to send in place of PMT_NUMB, with the HMAC signing recipe, test vectors, response fields and optional response verification.

Source: https://developer.inoviopay.com/api/tokenization.html  
Markdown: https://developer.inoviopay.com/api/tokenization.md

Rather than submitting the card number (`PMT_NUMB`) with a transaction, you can send a token in its place. You obtain the token by posting the card number to the token service endpoint below, which answers with a `TOKEN_GUID`. On the later sale or authorization request, send `TOKEN_GUID` instead of `PMT_NUMB`. Treat each token as single-use and short-lived: request a new one for every transaction and send it promptly.

**POST** `https://api.inoviopay.com/payment/token_service.cfm`

This endpoint does not use `req_username` and `req_password`. The request is authenticated by an HMAC signature over three of the form fields, computed with your Shared Key (see [Signing the request](#signing-the-request)). All parameters are sent as `application/x-www-form-urlencoded` fields in the POST body, exactly as shown in the cURL example. A request containing only the six fields in the table below, with no username, password or `Authorization` header, is accepted.

There is no separate sandbox host. You test against this production endpoint with a site that is configured to a test-bank merchant account, as described under [API Overview](https://developer.inoviopay.com/api/overview.md#overview).

| Parameter | Description |
|---|---|
| `card_pan` (required) | Customer card number to be tokenized. |
| `request_api_version` (required) | Must be sent as `4.14`. |
| `site_id` (required) | Merchant's website ID. Part of the signed message. |
| `unique_id` (required) | Alphanumeric ID for this request (Max 32 chars). Generate a new one for every request. Part of the signed message. |
| `x-timestamp` (required) | Request time in UTC, format `YYYYMMDDHHMMSS` (for example `20241209230900`). The gateway accepts the request for 5 minutes from this time. A timestamp older than that, or ahead of the gateway's clock, is rejected with error 406, so keep your clock synchronized. |
| `x-signature` (required) | Hex-encoded HMAC-SHA256 of `x-timestamp` + `unique_id` + `site_id` (concatenated in that order, no separators, card number excluded), keyed with your Shared Key. Either hex case is accepted. See [Signing the request](#signing-the-request). |

> **The HMAC signature excludes the card number**
> The gateway validates `hmac_sha256(timestamp || unique_id || site_id, site_key)`, with the PAN excluded from the signed message. Signing with the PAN included fails with error 121.
>
> Sign `x-timestamp`, `unique_id` and `site_id` only, omit `card_pan` from the signed string, and check your code against the test vector under [Signing the request](#signing-the-request).

> **The shared key is not your API password**
> The shared key used to compute the HMAC is a separate client-level HMAC secret; one key covers every site on your Inovio client. It is not the same as your `req_password` API password used on `pmt_service.cfm` requests. You generate it yourself in the Inovio portal under **Dev Tools → Key Manager**, where you can also rotate or revoke it.

> **TOKEN_GUID still requires pmt_expiry**
> A `TOKEN_GUID` replaces `PMT_NUMB` only, in a subsequent sale or auth request. You must still send `pmt_expiry` (and `pmt_key`, when the processor requires it) alongside `TOKEN_GUID`. Omitting `pmt_expiry` causes the API to respond with error 110, "Required field".

> **Treat the token as single-use and short-lived**
> Request a new `TOKEN_GUID` for every transaction, send it as soon as you receive it, and do not store it. A `TOKEN_GUID` is not a saved card: to charge the same customer again, use the `CUST_ID` and `PMT_ID` returned by the first transaction, as described under [Card on File](https://developer.inoviopay.com/api/card-on-file.md#cof).

**cURL**

```bash
# x-signature below is HMAC-SHA256("20241209230900" + "REQ999" + "12345") keyed with the
# dummy Shared Key 0123456789abcdef0123456789abcdef. Replace the timestamp with the current
# UTC time, use a fresh unique_id, and recompute with your own key before sending.
curl -X POST "https://api.inoviopay.com/payment/token_service.cfm" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "card_pan=4111111111111111&request_api_version=4.14&site_id=12345&unique_id=REQ999&x-timestamp=20241209230900&x-signature=1f214a2534be7800d79220049541408727c4b5613f5f1cb6fb14b01489c58544"
```

**PHP**

```php
use Inovio\Gateway\{Credentials, InovioClient};
use Inovio\Gateway\Model\{LineItem, Money, PaymentMethods};
use Inovio\Gateway\Request\TransactionRequest;

// The shared key is a separate constructor argument, not your req_password.
$client = new InovioClient(
    new Credentials('api_user', 'P@ssw0rd!', '12345'),
    'SANDBOX',
    siteKey: 'your-hmac-site-key'
);

$token = $client->tokenize(PaymentMethods::card('4111111111111111', '122026', '123'));

echo $token->token->guid(), "\n";

// The token replaces the PAN only — expiry travels with it automatically.
$sale = $client->sale((new TransactionRequest(
    $token->token,
    [new LineItem('SKU-1', 1, Money::of('10.00', 'USD'))]
))->withIdempotency('TOK-ORDER-1'));

echo $sale->status, "\n";
```

**Node**

```ts
import { InovioClient, Money, PaymentMethods } from '@inovio/gateway-sdk';

// The shared key is passed via client options, not req_password.
const client = new InovioClient(
  { reqUsername: 'api_user', reqPassword: 'P@ssw0rd!', siteId: '12345' },
  { environment: 'SANDBOX', siteKey: 'your-hmac-site-key' }
);

const token = await client.tokenize(PaymentMethods.card('4111111111111111', '122026', '123'));

console.log(token.token.guid);

// The token replaces the PAN only — expiry travels with it automatically.
const sale = await client.sale({
  paymentMethod: token.token,
  lineItems: [{ productId: 'SKU-1', count: 1, value: Money.of('10.00', 'USD') }],
  idempotency: { xtlOrderId: 'TOK-ORDER-1' },
});

console.log(sale.status);
```

**Python**

```python
from inovio_gateway import Credentials, InovioClient, Money, PaymentMethods
from inovio_gateway.model import Idempotency, LineItem
from inovio_gateway.request import TransactionRequest

# The shared key is a client constructor argument, not the API password.
client = InovioClient(
    Credentials("api_user", "P@ssw0rd!", "12345"),
    environment="SANDBOX",
    site_key="your-hmac-site-key",
)

token = client.tokenize(PaymentMethods.card("4111111111111111", "122026", "123"))

print(token.token.guid)

# The token replaces the PAN only — expiry travels with it automatically.
req = TransactionRequest(
    payment_method=token.token,
    line_items=[LineItem("SKU-1", 1, Money.of("10.00", "USD"))],
    idempotency=Idempotency(xtl_order_id="TOK-ORDER-1"),
)
sale = client.sale(req)

print(sale.status.value)
```

**Java**

```java
// The shared key is set on InovioClient.Options, not the req_password field.
InovioClient.Options options = new InovioClient.Options();
options.siteKey = "your-hmac-site-key";
InovioClient client = new InovioClient(
    new InovioClient.Credentials("api_user", "P@ssw0rd!", "12345"), options);

Tokenize.Result token = client.tokenize(
    PaymentMethods.card("4111111111111111", "122026", "123"));

System.out.println(token.token().guid());

// The token replaces the PAN only — expiry travels with it automatically.
TransactionRequest req = new TransactionRequest(
    token.token(), new LineItem("SKU-1", 1, Money.of("10.00", "USD")))
    .idempotency("TOK-ORDER-1");
TransactionResult sale = client.sale(req);

System.out.println(sale.status());
```

**Response**

```json
{
  "TOKEN_GUID": "3A393EBC3A266B9842FC55DB44B5401C697A5768",
  "CARD_BRAND_NAME": "",
  "CARD_TYPE": "",
  "CARD_BANK": "",
  "CARD_COUNTRY": "",
  "CARD_ACCOUNT_FUND_SOURCE": "",
  "CARD_CLASS": "",
  "TOKEN_IP": "192.168.1.1",
  "TOKEN_REQID": 73393758223
}
```

## Signing the request

1. Take the current UTC time as `YYYYMMDDHHMMSS`. This is `x-timestamp`.
2. Generate a new `unique_id` for every request (alphanumeric, up to 32 characters).
3. Build the message by concatenating `x-timestamp`, `unique_id` and `site_id` with nothing between them. Do not include `card_pan`.
4. Compute HMAC-SHA256 over that message with your Shared Key as the key, and hex-encode the result. This is `x-signature`.
5. Send all six fields in the form body.

Check your implementation against these vectors before using a real key. Both use the dummy Shared Key `0123456789abcdef0123456789abcdef` and `site_id` `12345`.

| Input | Vector A | Vector B |
|---|---|---|
| `x-timestamp` | `20241209230900` | `20260101120000` |
| `unique_id` | `REQ999` | `REQ001` |
| Message | `20241209230900REQ99912345` | `20260101120000REQ00112345` |
| Expected `x-signature` | `1f214a2534be7800d79220049541408727c4b5613f5f1cb6fb14b01489c58544` | `01e3958a9439e5384571aa2dfb7e4b9df9a7c596e27123924b7a7d8d014fb92f` |

Vector A is the request in the cURL example above. Reproduce it from a shell:

```bash
printf '%s' "20241209230900REQ99912345" | openssl dgst -sha256 -hmac "0123456789abcdef0123456789abcdef"
# 1f214a2534be7800d79220049541408727c4b5613f5f1cb6fb14b01489c58544
```

If your code produces `759b1cd0557d418ac04514cdbf06996f69bb616f1c8b45b98cb989a1ea15de8c` for the Vector B inputs, it is including the card number `4111111111111111` in the message; the gateway rejects that with error 121.

> **Postman**
> Postman cannot compute the signature from a static body. Add the pre-request script below to the request and reference `{{ts_value}}`, `{{unique_id_value}}` and `{{signature_value}}` in the `x-timestamp`, `unique_id` and `x-signature` body fields. Keep `site_key` in a Postman variable's Current Value only, so it is never exported with the collection.

```javascript
const siteKey = pm.variables.get('site_key');
const siteId  = pm.variables.get('site_id');
const d = new Date(), p = n => String(n).padStart(2, '0');
const timestamp = d.getUTCFullYear() + p(d.getUTCMonth() + 1) + p(d.getUTCDate())
                + p(d.getUTCHours()) + p(d.getUTCMinutes()) + p(d.getUTCSeconds());
const uniqueId  = ('REQ' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)).toUpperCase().slice(0, 32);
const signature = CryptoJS.HmacSHA256(timestamp + uniqueId + siteId, siteKey).toString(CryptoJS.enc.Hex);
pm.variables.set('ts_value', timestamp);
pm.variables.set('unique_id_value', uniqueId);
pm.variables.set('signature_value', signature);
```

## Response

The token service answers HTTP 200 for both success and failure, so check the body rather than the status code. A success body carries `TOKEN_GUID` and the fields below.

| Field | Description |
|---|---|
| `TOKEN_GUID` | The token. Send it as `TOKEN_GUID` on the sale or authorization request in place of `PMT_NUMB`. |
| `CARD_BRAND_NAME`, `CARD_TYPE`, `CARD_BANK`, `CARD_COUNTRY`, `CARD_ACCOUNT_FUND_SOURCE`, `CARD_CLASS` | Best-effort BIN metadata. Always present; empty strings when the BIN is not in the lookup table. Treat empty as unknown. |
| `TOKEN_IP` | The IP address the request came from. |
| `TOKEN_REQID` | Numeric request id. Quote it when contacting support. |

A failure body has no `TOKEN_GUID` and instead carries `error_code`, `error_message`, `error_detail` and `error_advice`:

```json
{
  "error_code": "121",
  "error_message": "Get CCtoken GUID signature match fail.",
  "error_detail": "",
  "error_advice": ". Reference ID 43624025"
}
```

Error `121` means the signature did not match: check that the message is `x-timestamp` + `unique_id` + `site_id` with no card number, and that the key is the Shared Key from **Dev Tools → Key Manager**, not the API password. Error `406` means `x-timestamp` is outside the accepted window: send the current UTC time and check your clock. Both are listed under [API Response Codes](https://developer.inoviopay.com/reference/api-codes.md).

### Verifying the response signature (optional)

A successful response carries two headers, `X-TIMESTAMP` (the gateway's UTC time as `YYYYMMDDHHMMSS`) and `X-SIGNATURE`. You can use them to check that the response came from Inovio and was not altered in transit. `X-SIGNATURE` is the uppercase hex HMAC-SHA256, keyed with your Shared Key, of `X-TIMESTAMP` + `TOKEN_REQID` + the response body, concatenated with nothing between them. Use the `TOKEN_REQID` digits as they appear in the body, and hash the body bytes exactly as received, with only the single trailing newline removed. Compare hex values case-insensitively. Failure responses carry neither header.

```python
import hashlib, hmac

def response_is_authentic(site_key: str, x_timestamp: str, token_reqid: int, raw_body: bytes, x_signature: str) -> bool:
    body = raw_body[:-1] if raw_body.endswith(b"\n") else raw_body
    message = x_timestamp.encode() + str(token_reqid).encode() + body
    expected = hmac.new(site_key.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.upper(), x_signature.upper())
```
