HC
PORTAL help
← Help Center
REST API v1

PORTAL API

Build integrations, automate workflows, and connect your repair shop with the tools you already use. Full REST API with webhooks, scoped authentication, and a plugin marketplace.

https://{your-shop}.portalhq.app/api/v1/
30+
Endpoints
22
Webhook Events
25+
Scopes
HMAC
Signed

Overview

The PORTAL API is a RESTful JSON API for reading and writing repair tickets, customers, inventory, devices, and more. Every request requires a Bearer token. All dates are ISO 8601. Monetary values are decimals (not cents).

Quick Start

  1. Get an API key from Settings → API Integrations in your PORTAL dashboard
  2. Include it as Authorization: Bearer {key} in every request
  3. Make requests to https://{shop}.portalhq.app/api/v1/{resource}
  4. All responses are JSON with "success": true/false

Authentication

Include your API key in the Authorization header on every request:

GET /api/v1/tickets HTTP/1.1 Host: yourshop.portalhq.app Authorization: Bearer pk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 Content-Type: application/json
⚠️ Keep your key secret. Never expose it in client-side code, public repos, or browser requests. Always call the API from your server.

Key Types

PrefixTypePurpose
pk_live_Plugin KeyProduction access for installed plugins
dk_test_Developer KeySandbox testing during development
ik_live_Internal KeyPORTAL internal services

Errors & Responses

Successful responses return "success": true with a data field. Errors include a machine-readable code:

✓ success
{ "success": true, "data": { ... }, "meta": { "page": 1, "total": 247 } }
✕ error
{ "success": false, "error": { "code": "NOT_FOUND", "message": "Ticket not found", "status": 404 } }
CodeHTTPMeaning
UNAUTHORIZED401Missing or invalid API key
FORBIDDEN403Key doesn't have required scope
NOT_FOUND404Resource doesn't exist
VALIDATION_ERROR422Invalid request body
RATE_LIMITED429Slow down — see headers
INTERNAL_ERROR500Server error (not your fault)

Rate Limiting

30/min
Free Plugins
120/min
Paid Plugins
300/min
Partners

Every response includes rate limit headers:

X-RateLimit-Limit: 120 X-RateLimit-Remaining: 117 X-RateLimit-Reset: 1711234620 # Unix timestamp

Pagination & Filtering

All list endpoints support pagination (default 50, max 200) and cursor-based navigation:

# Page-based GET /api/v1/tickets?page=2&per_page=25 # Cursor-based GET /api/v1/tickets?since_id=1234 GET /api/v1/tickets?updated_after=2026-01-01T00:00:00Z # Filtering + Sorting GET /api/v1/tickets?status=In+Progress&assigned_to=5&sort=updated_at&order=desc

Tickets

Create, read, update tickets and manage notes, line items, and payments.

Customers

Manage customer records, search, and view ticket history.

Catalog & Inventory

Manage your parts catalog and stock levels.

Devices

View device inventory (phones, tablets, etc. in the buy/sell pipeline).

Purchase Orders

Create and manage purchase orders for supplier ordering.

Store & Users

Shop info, ticket statuses, feature flags, and active users.

Webhooks Management

Register, update, and delete webhook endpoints programmatically.

Webhook Events

When events occur in PORTAL, we send an HTTP POST to your registered webhook URL with a signed JSON payload.

Payload Structure

{ "event": "ticket.status_changed", "event_id": "evt_a1b2c3d4e5", "timestamp": "2026-03-15T14:22:00Z", "store_id": 1, "data": { "ticket_id": 1042, "changes": { "status": { "from": "New", "to": "In Progress" } } } }

Webhook Security

Every delivery includes HMAC-SHA256 signature headers for verification:

X-Portal-Signature: sha256=a1b2c3d4e5f6... X-Portal-Timestamp: 1711234567 X-Portal-Event: ticket.created X-Portal-Delivery: evt_a1b2c3d4e5

Verification — PHP

$payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_PORTAL_SIGNATURE']; $timestamp = $_SERVER['HTTP_X_PORTAL_TIMESTAMP']; // Reject if older than 5 minutes (replay protection) if (abs(time() - (int)$timestamp) > 300) { http_response_code(401); exit; } // Compute and compare $expected = 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $payload, $webhookSecret ); if (!hash_equals($expected, $signature)) { http_response_code(401); exit; } $event = json_decode($payload, true); // Process event...

Verification — Node.js

const crypto = require('crypto'); function verifyWebhook(payload, signature, timestamp, secret) { if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(timestamp + '.' + payload) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signature) ); }
Retry policy: Failed deliveries retry up to 5 times: 1 min → 5 min → 30 min → 2 hours → 24 hours. After 50 consecutive failures, the webhook is auto-disabled.

Scopes Reference

Each API key is granted specific scopes controlling what data it can access. Plugins declare required scopes in their manifest; shop owners approve them on install.

Plugin Manifest

Every plugin is defined by a portal-plugin.json manifest file submitted for review:

{ "manifest_version": 1, "id": "quickbooks-sync", "name": "QuickBooks Sync", "version": "1.2.0", "description": "Sync invoices and payments with QuickBooks Online.", "author": { "name": "RepairTech Solutions", "website": "https://repairtechsolutions.com" }, "category": "accounting", "pricing": { "model": "monthly", "price": 9.99, "trial_days": 14 }, "scopes": ["tickets.read", "customers.read", "store.read"], "webhooks": [ { "event": "ticket.closed", "url": "https://api.example.com/webhook" } ], "widgets": [ { "id": "qb-status", "location": "ticket_sidebar", "url": "https://app.example.com/widget/qb-status", "height": 120 } ], "settings_url": "https://app.example.com/portal/settings" }

Plugin Categories

UI Widgets

Plugins can embed small UI panels inside PORTAL pages via sandboxed iframes, communicating with the host page through postMessage.

Widget Locations

Location IDWhereMax Height
ticket_sidebarTicket detail right sidebar300px
ticket_toolbarTicket toolbar action buttons40px
customer_sidebarCustomer profile sidebar300px
dashboard_cardMain dashboard area400px
settings_panelPlugin settings pageUnlimited
queue_bannerTop of ticket queue80px

Communication

// Widget → PORTAL: Request current ticket data window.parent.postMessage({ type: 'portal:request', resource: 'ticket', id: 'current' }, '*'); // PORTAL → Widget: Receive data window.addEventListener('message', (event) => { if (event.data.type === 'portal:response') { const ticket = event.data.data; // Render your widget UI with ticket data } }); // Widget → PORTAL: Add a note window.parent.postMessage({ type: 'portal:action', action: 'add_note', data: { text: 'Synced to QuickBooks: INV-1042' } }, '*');
Widget tokens are single-use and expire after 5 minutes. PORTAL appends a ?token=wt_... parameter when loading your widget iframe. Exchange it via POST /api/v1/widgets/verify-token to get context data.
PORTAL API · v1 portalhq.app ↗