User Profile API — Developer Manual

This API lets a third-party application authenticate a user against the ImpAcc user database and read that user's profile. It uses Laravel Sanctum bearer tokens.

Tokens issued by /api/login do not expire automatically. Call /api/logout (or tokens()->delete() on the server) to revoke a token.

Versions: this manual documents v1 (the default /api/* endpoints). A hardened, opt-in v2 is available under /api/v2/* (expiring tokens, rate limiting, strict validation, audit logging) — see §8. v3 (/api/v3/*) adds encrypted response payloads on top of v2 — see §9.


1. Authentication flow

            ┌──────────────┐   1. POST /api/login   ┌────────────────┐
            │              │  citizen_id + password │                │
            │  Your app    │ ─────────────────────► │  Profile API   │
            │ (3rd party)  │ ◄───────────────────── │ (usr.impacc)   │
            │              │     bearer token       │                │
            └──────────────┘                        └────────────────┘
                  │   2. Store token (memory / secure storage)
                  │
                  │   3. Every subsequent call carries the header:
                  │      Authorization: Bearer <token>
                  ▼
            GET  /api/profile
            GET  /api/permissions
            POST /api/logout

Two ways to obtain the bearer token:

Both return the same { token, token_type, user } payload; everything after step 2 is identical.

Required request headers

Header Value When
Accept application/json always
Content-Type application/json on POST/PUT
Authorization Bearer <token> on protected EPs

If Accept: application/json is missing, Laravel may return HTML redirects instead of JSON on auth failures. Always send it.


2. Endpoints

2.1 POST /api/login

Exchange credentials for a bearer token.

Request body

Field Type Required Notes
citizen_id string yes The user's 13‑digit citizen ID.
password string yes The user's plain‑text password (sent over HTTPS).
device_name string no Label saved with the token, e.g. "mobile-app-ios".

Example

curl -X POST https://uat-usr.impacc.work/api/login \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
        "citizen_id":  "1234567890123",
        "password":    "user-secret",
        "device_name": "partner-app-web"
      }'

Success — 200 OK

{
  "token":      "12|H7c0gQ9b3l...redacted...XyZ",
  "token_type": "Bearer",
  "user": {
    "id":                1234,
    "citizen_id":        "1234567890123",
    "title":             "นาย",
    "firstname":         "สมชาย",
    "lastname":          "ใจดี",
    "title_english":     "Mr.",
    "firstname_english": "Somchai",
    "lastname_english":  "Jaidee",
    "email":             "somchai@example.com",
    "mobile":            "0812345678",
    "born_date":         "1990-05-12",
    "workgroup":         "IT",
    "workgroup_id":      39,
    "division_id":       2,
    "organization":      "Impacc",
    "role_type1":        "officer",
    "role_type2":        null,
    "role_type3":        null,
    "status":            "1",
    "roles":             ["user"],
    "last_login":        "2026-05-19T08:42:11.000000Z",
    "current_profile":   { "...": "see profiles[] entry below" },
    "profiles": [
      {
        "id":                  1,
        "label":               "ต้นสังกัด",
        "kind":                "home",
        "dept1":               "สำนักงาน ป.ป.ท.",
        "dept2":               "สำนักงาน ป.ป.ท.",
        "dept3":               null,
        "officer_type_id":     4,
        "officer_type_name":   "ทั่วไป",
        "position_type_id":    3,
        "position_type_name":  "วิชาการ",
        "position":            "นักวิชาการ",
        "description":         null,
        "level":               "ปฏิบัติการ",
        "management_position": null,
        "is_default":          true,
        "is_active":           true,
        "is_temporary":        false,
        "mission_title":       null,
        "mission_note":        null,
        "mission_start_date":  null,
        "mission_end_date":    null,
        "mission_order_no":    null,
        "mission_order_file":  null,
        "status":              "approved",
        "approved_at":         "2024-07-10T08:00:00.000000Z"
      },
      {
        "id":                  4,
        "label":               "คณะทำงานพิเศษ",
        "kind":                "mission",
        "dept1":               "รองเลขาธิการ ป.ป.ท. (คนที่ 1)",
        "dept2":               "งานรองเลขาธิการ ป.ป.ท. (คนที่ 1)",
        "dept3":               null,
        "officer_type_id":     2,
        "officer_type_name":   "เจ้าหน้าที่ ป.ป.ท.",
        "position_type_id":    null,
        "position_type_name":  null,
        "position":            null,
        "description":         null,
        "level":               null,
        "management_position": null,
        "is_default":          false,
        "is_active":           false,
        "is_temporary":        true,
        "mission_title":       "สืบสวน",
        "mission_note":        "ช่วยราชการคณะทำงาน X",
        "mission_start_date":  "2026-01-01",
        "mission_end_date":    "2026-06-30",
        "mission_order_no":    "123/2569",
        "mission_order_file":  "orders/2569-123.pdf",
        "status":              "approved",
        "approved_at":         "2025-12-20T03:00:00.000000Z"
      }
    ]
  }
}

profiles[] is ordered with the currently active profile first, then the default, then the rest. current_profile is a convenience pointer to the same row a consumer would otherwise have to find by scanning profiles[] for is_active=true (with is_default=true as a fallback). If the user has no rows in user_profiles, both current_profile is null and profiles[] is empty.

The user object also carries a permissions block — the user's effective applications, menus and sections. Its structure is documented under §2.4 GET /api/permissions; it is omitted from the example above for brevity but is always present.

Store the token securely on the client. Send it on every later call as Authorization: Bearer <token>.

Failure — 422 Unprocessable Entity (wrong credentials or inactive account)

{
  "message": "The provided credentials are incorrect.",
  "errors": {
    "citizen_id": ["The provided credentials are incorrect."]
  }
}

Failure — 422 Unprocessable Entity (missing fields)

{
  "message": "The citizen id field is required. (and 1 more error)",
  "errors": {
    "citizen_id": ["The citizen id field is required."],
    "password":   ["The password field is required."]
  }
}

2.2 GET /api/profile

Return the authenticated user's profile. The user object is identical to the one returned by POST /api/login — it includes current_profile, the full profiles[] array, and the permissions block (see §2.4). The example below shows only the user-level fields for brevity.

Headers

Accept: application/json
Authorization: Bearer <token>

Example

curl https://uat-usr.impacc.work/api/profile \
  -H "Accept: application/json" \
  -H "Authorization: Bearer 12|H7c0gQ9b3l...XyZ"

Success — 200 OK

{
  "user": {
    "id":                1234,
    "citizen_id":        "1234567890123",
    "title":             "นาย",
    "firstname":         "สมชาย",
    "lastname":          "ใจดี",
    "title_english":     "Mr.",
    "firstname_english": "Somchai",
    "lastname_english":  "Jaidee",
    "email":             "somchai@example.com",
    "mobile":            "0812345678",
    "born_date":         "1990-05-12",
    "workgroup":         "IT",
    "workgroup_id":      39,
    "division_id":       2,
    "organization":      "Impacc",
    "role_type1":        "officer",
    "role_type2":        null,
    "role_type3":        null,
    "status":            "1",
    "roles":             ["user"],
    "last_login":        "2026-05-19T08:42:11.000000Z"
  }
}

Failure — 401 Unauthenticated (missing, invalid, or revoked token)

{ "message": "Unauthenticated." }

2.3 POST /api/logout

Revoke the bearer token used on this request. After this call, the token can no longer be used.

Headers

Accept: application/json
Authorization: Bearer <token>

Example

curl -X POST https://uat-usr.impacc.work/api/logout \
  -H "Accept: application/json" \
  -H "Authorization: Bearer 12|H7c0gQ9b3l...XyZ"

Success — 200 OK

{ "message": "Logged out" }

Failure — 401 Unauthenticated

{ "message": "Unauthenticated." }

2.4 GET /api/permissions

Return the authenticated user's effective access across the three-tier permission model: application → menu → section. The same object is also embedded as user.permissions in the login and profile responses; this endpoint returns it on its own when that is all you need.

How access is resolved

A permission is effective for a user when it is granted at that tier and not blocked:

effective(tier) = ( grants from the user's groups )      ← groups come from BOTH
                ∪ ( the user's direct grants )              users.workgroup_id → workgroup_groups
                ∖ ( the user's blocks )                     AND direct user_groups membership

Menus are nested under an application only when that application is itself effective; sections are nested under an effective menu likewise.

The section tier exists in the schema but currently holds no data, so sections is presently [] for every menu. It will populate automatically once section permissions are assigned — no client change needed.

Headers

Accept: application/json
Authorization: Bearer <token>

Example

curl https://uat-usr.impacc.work/api/permissions \
  -H "Accept: application/json" \
  -H "Authorization: Bearer 12|H7c0gQ9b3l...XyZ"

Success — 200 OK

{
  "permissions": {
    "group_ids": [1, 2, 3, 22, 23],
    "applications": [
      {
        "id":     22,
        "app_id": "11-22",
        "name":   "ระบบDIDC",
        "link":   "/miniapp/didc",
        "menus": [
          {
            "id":     81,
            "name":   "จัดการ Dashboard",
            "path":   "/dashboard-management",
            "level":  2,
            "parent": 75,
            "sections": []
          }
        ]
      },
      {
        "id":     13,
        "app_id": "11-13",
        "name":   "ระบบประชาสัมพันธ์-ภายนอก",
        "link":   "/miniapp/xcms",
        "menus": []
      }
    ]
  }
}

An application with no effective menus returns "menus": [] (the user can reach the app but no specific menu has been granted/resolved under it).

Failure — 401 Unauthenticated

{ "message": "Unauthenticated." }

2.5 POST /api/sso/exchange

Single Sign-On token exchange. This is an alternative to POST /api/login for callers that are launched from the imPACC portal (impacc.work) instead of collecting a password.

When a user already logged into the portal opens a linked app, the portal mints a one-time mToken on that user's record and redirects the browser to the app with it (e.g. https://your-app?appId=...&mToken=...). The app posts that mToken here to obtain a bearer token and the user's profile — without ever handling the user's credentials.

mToken is single-use. It is cleared on the first successful exchange, so it cannot be replayed. A fresh mToken is minted by the portal on each launch. Exchange it promptly.

Request body

Field Type Required Notes
mToken string yes The one-time token from the portal redirect.
device_name string no Label saved with the issued token, e.g. "sso-web".

Example

curl -X POST https://uat-usr.impacc.work/api/sso/exchange \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "mToken": "0f3a…64-char-token…d9", "device_name": "sso-web" }'

Success — 200 OK

Identical shape to POST /api/logintoken, token_type, and the full user object (including current_profile, profiles[], and the permissions block from §2.4):

{
  "token":      "9|iU7SMJ2TjJ7feNnG…redacted…",
  "token_type": "Bearer",
  "user": { "id": 1, "citizen_id": "…", "permissions": { "…": "see §2.4" } }
}

Failure — 422 Unprocessable Entity (unknown, already-used, or expired mToken, or an inactive account)

{
  "message": "Invalid or expired SSO token.",
  "errors": { "mToken": ["Invalid or expired SSO token."] }
}

After exchange, treat the returned token exactly like one from /login: send it as Authorization: Bearer <token> on every subsequent call, and call POST /api/logout to revoke it.


3. Profile field reference

3.1 User-level fields

Field Type Description
id integer Internal user ID.
citizen_id string 13‑digit citizen ID. Primary external identifier.
title string Thai title (นาย / นาง / นางสาว…).
firstname string Thai first name.
lastname string Thai last name.
title_english string English title (Mr./Mrs./Ms.).
firstname_english string English first name.
lastname_english string English last name.
email string Email address.
mobile string Mobile phone number.
born_date date YYYY-MM-DD.
workgroup string Work group / team name on the user row.
workgroup_id integer/null FK to workgroups. Drives group/permission resolution (see §2.4).
division_id integer/null FK to divisions — the user's organizational division.
organization string Organization name on the user row.
role_type1..3 string Role classifiers (free form, project specific).
status string "1" = active. Other values are inactive.
roles string[] Spatie permission role names assigned.
last_login datetime ISO‑8601 UTC of the most recent login.
current_profile object/null Convenience pointer — same shape as a profiles[] item. The user's currently active profile, falling back to the default.
profiles[] object[] Every OU / mission assignment the user has. See §3.2.
permissions object The user's effective applications/menus/sections. See §2.4 and §3.3.

password, remember_token, and uploaded user-file paths are intentionally not returned.

3.2 Profile (OU / mission) fields

Each entry in profiles[] represents one organizational-unit assignment for the user. A user always has a "home" OU (is_default=true), may have one or more secondary OUs, and may have one or more special-mission OUs (is_temporary=true with the mission_* fields populated).

Field Type Description
id integer Internal profile row ID. Stable across requests.
label string Display label ("ต้นสังกัด", "สังกัดเพิ่มเติม", "ภารกิจพิเศษ", …).
kind enum Convenience classifier: "home", "secondary", or "mission".
dept1 string Top-level department (DivisionLevel1).
dept2 string Mid-level department (DivisionLevel2).
dept3 string Lowest-level department (DivisionLevel3).
officer_type_id integer FK to officer_types.
officer_type_name string Joined name from officer_types.
position_type_id integer FK to ds_positions_types.
position_type_name string Joined name from ds_positions_types.
position string Position title.
description string Free-text description (e.g., position description level).
level string Position level (e.g., "ปฏิบัติการ", "ชำนาญการ").
management_position string Management-track position name, if applicable.
is_default bool true for the user's primary/home OU. Exactly one row per user.
is_active bool true for the OU the user is currently working under in the web app.
is_temporary bool true for special-mission OUs; the mission_* fields below will be set.
mission_title string Name of the special mission (e.g., "สืบสวน").
mission_note string Free-text note about the mission.
mission_start_date date YYYY-MM-DD.
mission_end_date date YYYY-MM-DD. After this date the row is considered ended.
mission_order_no string Order/decree number authorizing the mission.
mission_order_file string Path to the scanned order document, relative to the file storage root.
status string "approved", "pending", "ended", etc.
approved_at datetime When this assignment was approved.

How to interpret kind:

kind Condition Meaning
"home" is_default=true The user's primary/permanent OU.
"mission" is_default=false, is_temporary=true Special mission, time‑bounded.
"secondary" is_default=false, is_temporary=false Additional non-mission assignment.

Consumers that only need the user's main OU can use current_profile directly. Consumers that present a list (e.g., "switch active OU" menus) should iterate profiles[].

3.3 Permissions fields

The permissions object (returned standalone by §2.4 and embedded in user.permissions) has this shape:

Field Type Description
group_ids int[] Every group the user belongs to (workgroup-derived ∪ direct).
applications[] object[] Effective applications, ordered by sequence then id.
applications[].id integer Internal application ID (applications.id).
applications[].app_id string External application code (e.g. "11-22").
applications[].name string Application display name.
applications[].link string App entry path/URL, if any.
applications[].menus[] object[] Effective menus under this application ([] if none).
menus[].id integer Internal menu ID (menu_applications.id).
menus[].name string Menu display name.
menus[].path string Route/path for the menu.
menus[].level int/null Menu depth (1 = top level).
menus[].parent int/null Parent menu ID for nesting in a sidebar.
menus[].sections[] object[] Effective sections under this menu ([] until section data exists).
sections[].id integer Internal section ID (section_menus.id).
sections[].name string Section display name.

Only granted, non-blocked entries appear; absence of an application, menu, or section means the user has no effective access to it. Build a client-side "allowed paths" set from menus[].path to drive route guards.


4. HTTP status codes

Status Meaning
200 Success.
401 Token missing, invalid, or revoked. Re-login.
419 CSRF/session mismatch. You forgot Accept: application/json.
422 Validation error (bad credentials, missing fields).
429 Rate limited (default 60 req/min/IP on the api middleware).
500 Server error. Capture the response body and contact the API team.

5. Token handling — best practices


6. Sample integrations

6.1 JavaScript (fetch)

async function login(citizenId, password) {
  const r = await fetch('https://uat-usr.impacc.work/api/login', {
    method:  'POST',
    headers: {
      'Accept':       'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      citizen_id:  citizenId,
      password,
      device_name: 'web-partner-app',
    }),
  });
  if (!r.ok) throw new Error(`Login failed: ${r.status}`);
  return r.json(); // { token, token_type, user }
}

async function getProfile(token) {
  const r = await fetch('https://uat-usr.impacc.work/api/profile', {
    headers: {
      'Accept':        'application/json',
      'Authorization': `Bearer ${token}`,
    },
  });
  if (r.status === 401) throw new Error('Token expired');
  return r.json();
}

6.2 PHP (Guzzle)

use GuzzleHttp\Client;

$http = new Client(['base_uri' => 'https://uat-usr.impacc.work/api/']);

$login = $http->post('login', [
    'headers' => ['Accept' => 'application/json'],
    'json'    => [
        'citizen_id'  => '1234567890123',
        'password'    => $userPassword,
        'device_name' => 'partner-backend',
    ],
]);
$token = json_decode((string) $login->getBody(), true)['token'];

$profile = $http->get('profile', [
    'headers' => [
        'Accept'        => 'application/json',
        'Authorization' => "Bearer {$token}",
    ],
]);

6.3 Python (requests)

import requests

BASE = "https://uat-usr.impacc.work/api"

def login(citizen_id, password):
    r = requests.post(f"{BASE}/login", json={
        "citizen_id":  citizen_id,
        "password":    password,
        "device_name": "partner-python",
    }, headers={"Accept": "application/json"})
    r.raise_for_status()
    return r.json()["token"]

def profile(token):
    r = requests.get(f"{BASE}/profile", headers={
        "Accept":        "application/json",
        "Authorization": f"Bearer {token}",
    })
    r.raise_for_status()
    return r.json()["user"]

7. Support

For credentials, allow‑listing, or production access, contact the ImpAcc API team. Include:


8. API v2 — hardened (opt-in)

A second, hardened version of the API is available under the /api/v2 prefix. It speaks the same request/response contract as v1 (sections 2–3 apply unchanged, including the user payload and the permissions block) but adds the security measures below. v1 is unchanged — existing integrations keep working; adopt v2 by changing the URL prefix.

Endpoint v2 path
Login POST /api/v2/login
SSO exchange POST /api/v2/sso/exchange
Profile GET /api/v2/profile
Permissions GET /api/v2/permissions
Logout POST /api/v2/logout

8.1 What v2 adds over v1

Hardening Detail
Token expiry Issued bearer tokens expire after 8 hours. The token response includes an expires_in field (seconds). v1 tokens never expire.
Strict validation citizen_id must be exactly 13 digits (^[0-9]{13}$); otherwise 422.
Rate limiting POST /api/v2/login5 req/min/IP; POST /api/v2/sso/exchange10 req/min/IP. Exceeding returns 429 Too Many Requests.
SSO mToken TTL The one-time mToken is rejected if older than 120 seconds (in addition to being single-use). Exchange promptly.
Correct active check Accounts are gated on the active flag (not status), so valid users are not wrongly rejected.
Audit logging Every login / sso-exchange / logout (success and failure) is recorded server-side with IP and user-agent.

8.2 Token response (v2)

POST /api/v2/login and POST /api/v2/sso/exchange return the v1 shape plus expires_in:

{
  "token":      "42|nQlGT1UXax4Iu...redacted...",
  "token_type": "Bearer",
  "expires_in": 28800,
  "user": { "id": 1, "citizen_id": "...", "permissions": { "...": "see §2.4" } }
}

Treat the token exactly as in v1 (Authorization: Bearer <token>), but renew it before expires_in elapses — once expired, protected calls return 401 and you must log in / exchange again.

8.3 Example

# v2 login (note the 13-digit citizen_id and the v2 prefix)
curl -X POST https://uat-usr.impacc.work/api/v2/login \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{ "citizen_id": "1234567890123", "password": "user-secret", "device_name": "v2-web" }'

# v2 SSO exchange
curl -X POST https://uat-usr.impacc.work/api/v2/sso/exchange \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{ "mToken": "0f3a…64-char…d9" }'

8.4 Additional status codes (v2)

Status Meaning (v2)
429 Rate limit exceeded — back off and retry after the window.
401 Token missing/invalid/expired — re-login or re-exchange.
422 Validation error, bad credentials, inactive account, or expired/used mToken.

9. API v3 — encrypted payloads (opt-in)

v3 is everything in v2 plus response payload encryption, served under the /api/v3 prefix. Every successful response body is returned as a hybrid-encrypted envelope that only the calling client can decrypt — so the data is protected even past TLS termination (logging proxies, gateways, etc.). Error responses (4xx) stay plain JSON. v1 and v2 are unchanged.

9.1 How it works

  1. The client generates an RSA key pair and registers the public key → gets a client_id.
  2. The client sends X-Client-Id: <client_id> on every v3 request.
  3. The server encrypts each successful response with a fresh AES-256-GCM key, wraps that key with the client's RSA public key (RSA-OAEP), and returns the envelope.
  4. The client decrypts with its private key to get the normal v1/v2 JSON.

The private key never leaves the client. Without a valid X-Client-Id the server returns 400.

9.2 Endpoints

Endpoint v3 path Notes
Register key POST /api/v3/clients/register plain JSON in/out (bootstrap)
Login POST /api/v3/login encrypted response
SSO exchange POST /api/v3/sso/exchange encrypted response
Profile GET /api/v3/profile encrypted response
Permissions GET /api/v3/permissions encrypted response
Logout POST /api/v3/logout encrypted response

9.3 The encrypted envelope

{
  "encrypted": true,
  "alg": "RSA-OAEP+A256GCM",
  "key": "<base64: RSA-OAEP(aes_key)>",
  "iv":  "<base64: 12-byte GCM nonce>",
  "tag": "<base64: 16-byte GCM auth tag>",
  "ciphertext": "<base64: AES-256-GCM(plaintext_json)>"
}

Decrypt = RSA-OAEP-decrypt key → AES key, then AES-256-GCM-decrypt ciphertext with iv+tag. The result is the same JSON v1/v2 would return.

9.4 Walkthrough

# 1. generate a key pair
openssl genrsa -out client_priv.pem 2048
openssl rsa -in client_priv.pem -pubout -out client_pub.pem

# 2. register the public key -> client_id
curl -X POST https://uat-usr.impacc.work/api/v3/clients/register \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg k "$(cat client_pub.pem)" '{name:"partner-app", public_key:$k}')"
# -> { "client_id": "cli_...", "name": "partner-app" }

# 3. call a v3 endpoint with X-Client-Id  (response is an envelope)
curl -X POST https://uat-usr.impacc.work/api/v3/login \
  -H "Content-Type: application/json" -H "X-Client-Id: cli_..." \
  -d '{ "citizen_id":"1234567890123", "password":"user-secret" }'

Decrypt — PHP

$env  = json_decode($responseBody, true);
$priv = openssl_pkey_get_private(file_get_contents('client_priv.pem'));
openssl_private_decrypt(base64_decode($env['key']), $aesKey, $priv, OPENSSL_PKCS1_OAEP_PADDING);
$json = openssl_decrypt(
    base64_decode($env['ciphertext']), 'aes-256-gcm', $aesKey, OPENSSL_RAW_DATA,
    base64_decode($env['iv']), base64_decode($env['tag'])
);
$data = json_decode($json, true);   // { token, expires_in, user, ... }

Decrypt — Node.js

const crypto = require('crypto');
const env = JSON.parse(responseBody);
const aesKey = crypto.privateDecrypt(
  { key: fs.readFileSync('client_priv.pem'), padding: crypto.constants.RSA_PKCS1_OAEP_PADDING },
  Buffer.from(env.key, 'base64'));
const d = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(env.iv, 'base64'));
d.setAuthTag(Buffer.from(env.tag, 'base64'));
const json = Buffer.concat([d.update(Buffer.from(env.ciphertext, 'base64')), d.final()]).toString('utf8');
const data = JSON.parse(json);   // { token, expires_in, user, ... }

9.5 Status codes (v3)

Status Meaning
200 Success — body is an encrypted envelope (decrypt it).
400 X-Client-Id missing or unknown/inactive.
401 / 422 / 429 As in v2 — returned as plain JSON (errors are not encrypted).

9.6 Notes & caveats


Generated 2026-06-07 17:13 · ImpAcc User Profile API