Customer Portal
The Customer Portal provides a self-service interface where your customers can submit tickets, track their existing requests, reply to agents, and search your knowledge base. Authentication uses a passwordless magic link flow.
Authentication Flow
The portal uses a two-step magic link authentication:
- Customer requests a magic link by providing their email address
- Tiqora sends a one-time login link to that email (valid for 15 minutes)
- Customer clicks the link, which sends the token to the verify endpoint
- Tiqora returns a 7-day JWT for subsequent portal API calls
Step 1: Request a Magic Link
POST /api/v1/portal/auth/magic-link
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Customer's email address |
Example Request
{
"email": "jane@example.com"
}
Example Response (200 OK)
{
"data": {
"message": "If an account exists for this email, a login link has been sent.",
"expires_in": 900
}
}
Important: The response is intentionally vague to prevent email enumeration. A
200is returned whether or not the email exists in the system.
Step 2: Verify the Token
POST /api/v1/portal/auth/verify
The magic link email contains a URL with a token parameter. Your portal frontend extracts this token and sends it to the verify endpoint.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The magic link token from the email |
Example Request
{
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
}
Example Response (200 OK)
{
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 604800,
"customer": {
"id": "01912345-0000-7abc-def0-123456789abc",
"email": "jane@example.com",
"name": "Jane Smith"
}
}
}
Error: Invalid or Expired Token
{
"error": {
"code": "UNAUTHORIZED",
"message": "The login link is invalid or has expired. Please request a new one."
}
}
Step 3: Use the Portal JWT
Include the JWT in all subsequent portal requests:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
The portal JWT is distinct from the dashboard JWT. It contains:
| Claim | Description |
|---|---|
sub |
Customer ID (UUID) |
tid |
Tenant ID (UUID) |
type |
portal (distinguishes from agent tokens) |
exp |
Expiry timestamp (7 days from issuance) |
Get Current Customer
GET /api/v1/portal/auth/me
Returns the authenticated customer's profile.
Example Response
{
"data": {
"id": "01912345-0000-7abc-def0-123456789abc",
"email": "jane@example.com",
"name": "Jane Smith",
"phone": "+1 555-123-4567",
"company": "Acme Corp",
"created_at": "2024-11-01T12:00:00Z"
}
}
List Customer Tickets
GET /api/v1/portal/tickets
Returns tickets belonging to the authenticated customer. Only the customer's own tickets are visible.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
per_page |
integer | 25 | Results per page (max: 100) |
cursor |
string | — | Cursor for next page |
status |
string | — | Filter by status |
Example Response
{
"data": [
{
"id": "01912345-6789-7abc-def0-123456789abc",
"ticket_number": "1043",
"subject": "Payment not processing",
"status": "in_progress",
"priority": "high",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:30:00Z",
"last_reply_at": "2025-01-15T10:25:00Z"
},
{
"id": "01912345-5555-7abc-def0-123456789abc",
"ticket_number": "1038",
"subject": "How to export data?",
"status": "resolved",
"priority": "medium",
"created_at": "2025-01-12T14:00:00Z",
"updated_at": "2025-01-13T09:00:00Z",
"last_reply_at": "2025-01-13T09:00:00Z"
}
],
"meta": {
"per_page": 25,
"has_more": false,
"next_cursor": null
}
}
Get a Ticket
GET /api/v1/portal/tickets/{ticket_id}
Returns a single ticket with its conversation history. Internal notes are automatically excluded from the response.
Example Response
{
"data": {
"id": "01912345-6789-7abc-def0-123456789abc",
"ticket_number": "1043",
"subject": "Payment not processing",
"description": "I'm trying to upgrade my plan but the payment keeps failing.",
"status": "in_progress",
"priority": "high",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:30:00Z",
"replies": [
{
"id": "01912345-aaaa-7abc-def0-123456789abc",
"body": "Hi Jane, I can see the issue with your payment. Let me look into this for you.",
"author_type": "agent",
"author_name": "Alex Johnson",
"attachments": [],
"created_at": "2025-01-15T10:15:00Z"
}
]
}
}
Important: Internal notes (
is_internal_note: true) are never included in portal responses. Customers only see public replies from agents, other customer replies, and system messages.
Create a Ticket
POST /api/v1/portal/tickets
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
subject |
string | Yes | Ticket subject (max 500 chars) |
description |
string | Yes | Ticket description, Markdown supported (min 20 chars) |
priority |
string | No | low, medium, high. Default: medium |
category_id |
uuid | No | Category for the ticket |
Example Request
{
"subject": "Cannot download invoices",
"description": "When I click the download button on my invoices page, nothing happens. I've tried multiple browsers (Chrome and Firefox) on macOS. The issue started this morning.",
"priority": "medium"
}
Example Response (201 Created)
{
"data": {
"id": "01912345-7777-7abc-def0-123456789abc",
"ticket_number": "1044",
"subject": "Cannot download invoices",
"status": "new",
"priority": "medium",
"created_at": "2025-01-16T09:00:00Z"
}
}
Hint: Portal customers cannot set
urgentpriority. If the issue is truly urgent, an agent can escalate it from the dashboard.
Reply to a Ticket
POST /api/v1/portal/tickets/{ticket_id}/replies
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
body |
string | Yes | Reply content, Markdown supported |
Example Request
{
"body": "Thanks Alex! The payment went through now. However, I notice my plan still shows as **Basic**. Could you check if the upgrade was applied?"
}
Example Response (201 Created)
{
"data": {
"id": "01912345-cccc-7abc-def0-123456789abc",
"ticket_id": "01912345-6789-7abc-def0-123456789abc",
"body": "Thanks Alex! The payment went through now...",
"author_type": "customer",
"author_name": "Jane Smith",
"attachments": [],
"created_at": "2025-01-15T10:35:00Z"
}
}
Hint: When a customer replies to a ticket in
waiting_on_customerstatus, the ticket automatically transitions back toin_progress.
Search Knowledge Base
GET /api/v1/portal/kb/search
Search published knowledge base articles. Uses full-text and semantic search to return the most relevant results.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
q |
string | Yes | Search query (min 3 chars) |
per_page |
integer | No | Results per page (max: 20). Default: 10 |
category_id |
uuid | No | Filter by KB category |
Example Request
GET /api/v1/portal/kb/search?q=reset+password&per_page=5
Example Response
{
"data": [
{
"id": "01912345-8888-7abc-def0-123456789abc",
"title": "How to Reset Your Password",
"excerpt": "If you've forgotten your password, you can reset it in a few simple steps. Go to the login page and click 'Forgot Password'...",
"category": "Account Management",
"relevance_score": 0.94,
"updated_at": "2025-01-10T12:00:00Z"
},
{
"id": "01912345-9999-7abc-def0-123456789abc",
"title": "Two-Factor Authentication Setup",
"excerpt": "Secure your account with two-factor authentication. After setting a password, you can enable 2FA from your security settings...",
"category": "Account Management",
"relevance_score": 0.71,
"updated_at": "2025-01-08T15:00:00Z"
}
],
"meta": {
"total": 2,
"per_page": 5
}
}
Hint: Only articles with
visibility: publicare returned in portal search results. Draft and internal articles are excluded.
Portal Rate Limits
Portal endpoints have their own rate limits separate from the main API:
| Endpoint | Limit |
|---|---|
POST /portal/auth/magic-link |
3 requests per 15 minutes per email |
POST /portal/auth/verify |
5 attempts per 15 minutes per IP |
| All authenticated endpoints | 60 requests per minute per customer |
Best Practices
Handle token expiry gracefully — The portal JWT lasts 7 days. When it expires, redirect the customer to request a new magic link rather than showing a raw error.
Show KB results before ticket creation — When a customer starts creating a ticket, search the knowledge base for relevant articles. This can deflect tickets and reduce support load.
Hide internal details — The portal API automatically filters internal notes and agent-only metadata. Do not expose raw dashboard API responses to customers.
Use the magic link response message as-is — The vague "if an account exists" message is intentional. Do not add logic that reveals whether an email exists in your system.