# Mobile support chat: REST and realtime integration

This is the mobile contract for borrower support chat. Realtime delivery uses
Laravel Reverb's Pusher-compatible WebSocket protocol. Every HTTP request, including
private-channel authorization, uses the same Sanctum bearer token returned by login.

## 1. Base URLs

| Environment | REST base URL | WebSocket |
| --- | --- | --- |
| Local Android emulator | `http://10.0.2.2:8000/api/v1` | returned by realtime discovery |
| Local iOS simulator | `http://127.0.0.1:8000/api/v1` | returned by realtime discovery |
| Staging | `https://stagingapi.nextpaydayapp.com/api/v1` | returned by realtime discovery |
| Production | `https://api.nextpaydayapp.com/api/v1` | returned by realtime discovery |

Do not hard-code the Reverb key, host, port, channel, or authorization endpoint.
Fetch them after login.

## 2. Authentication header

```http
Authorization: Bearer {borrower_token}
Accept: application/json
```

## 3. Discover the realtime connection

```http
GET /support/realtime
```

Example response:

```json
{
  "data": {
    "enabled": true,
    "broadcaster": "reverb",
    "key": "public-reverb-app-key",
    "host": "socket.nextpaydayapp.com",
    "port": 443,
    "scheme": "https",
    "path": "",
    "auth_endpoint": "https://api.nextpaydayapp.com/api/v1/broadcasting/auth",
    "channel": "support.borrower.1042",
    "private_channel": "private-support.borrower.1042",
    "event": "support.conversation.changed"
  }
}
```

- Echo-style clients use `channel` and automatically add `private-`.
- Native Pusher-protocol clients generally use `private_channel` exactly as returned.
- `key` is public. The API never returns the Reverb secret.
- If `enabled` is false, fall back to HTTP polling and retry discovery later.

## 4. Authorize the private channel

Configure the WebSocket library's custom authorizer to call `auth_endpoint`.

```http
POST /api/v1/broadcasting/auth
Authorization: Bearer {borrower_token}
Accept: application/json
Content-Type: application/x-www-form-urlencoded

socket_id=123.456&channel_name=private-support.borrower.1042
```

The successful response contains the signed `auth` value expected by the Pusher
protocol. Treat `401` as an expired login and `403` as a channel-access failure.
Never retry `403` with another customer's channel.

Subscribe to the private inbox channel returned by discovery. Events are sent only
to the borrower inbox and the private inboxes of currently eligible support team
members. Conversation and staff channels no longer receive events. Removing a member
from the support team stops future events even if they retain an old subscription.

## 5. Event contract

Listen for `support.conversation.changed`. Laravel Echo listeners use the leading
dot form `.support.conversation.changed`; native Pusher clients use the name without
the dot.

```json
{
  "type": "message.created",
  "conversation_uuid": "4ce8f186-d42e-4b0d-a3ce-649fb5e66c65",
  "conversation": {
    "uuid": "4ce8f186-d42e-4b0d-a3ce-649fb5e66c65",
    "source": "borrower_app",
    "category": "mandate",
    "subject": "Mandate link is not opening",
    "status": "active",
    "priority": "normal",
    "last_message_at": "2026-09-30T11:42:10+01:00"
  },
  "message": {
    "id": 391,
    "sender_type": "affiliate",
    "body": "I am checking this now.",
    "attachment_url": null,
    "attachment_type": null,
    "client_message_id": "550e8400-e29b-41d4-a716-446655440012",
    "read_at": null,
    "created_at": "2026-09-30T11:42:10+01:00"
  },
  "meta": {},
  "occurred_at": "2026-09-30T11:42:10+01:00"
}
```

Possible `type` values:

| Type | Meaning | Mobile action |
| --- | --- | --- |
| `message.created` | Borrower or affiliate sent a message | Upsert `message` by numeric `id`, update the conversation list |
| `messages.read` | The other participant marked messages read | Refetch the open conversation to update receipts |
| `conversation.updated` | Conversation was accepted, resolved, or closed | Update/refetch status and disable input when closed |

Events are hints, not the source of truth. If an event is missed during reconnect,
`GET /support/conversations/{uuid}` returns the canonical conversation and messages.

`messages` are always ordered **oldest first** (by `created_at`, then `id`). Render them in
that order with the newest at the bottom — don't reverse the list, and when you append a
new/realtime message, add it to the end.

## 6. REST endpoints

All paths below are relative to the REST base URL.

### List conversations

```http
GET /support/conversations?page=1
```

### Open a conversation

```http
POST /support/conversations
Content-Type: application/json

{
  "category": "mandate",
  "subject": "Mandate link is not opening",
  "body": "Please help me continue.",
  "priority": "normal",
  "client_message_id": "550e8400-e29b-41d4-a716-446655440011"
}
```

Categories: `application`, `mandate`, `repayment`, `complaint`, `other`.
Priorities: `normal`, `high`.

### Load one conversation

```http
GET /support/conversations/{conversationUuid}
```

### Send a message

```http
POST /support/conversations/{conversationUuid}/messages
Content-Type: application/json

{
  "body": "Has the mandate been refreshed?",
  "client_message_id": "2f35107d-5067-4763-a6cd-477c828aec34"
}
```

Generate `client_message_id` once when the user taps Send and retain it while
retrying that message. Reusing it makes retries idempotent and prevents duplicates.

### Mark affiliate messages read

```http
POST /support/conversations/{conversationUuid}/read
Content-Type: application/json

{}
```

Call after the conversation is visible, not merely after receiving a background
event.

### Close a conversation

```http
POST /support/conversations/{conversationUuid}/close
Content-Type: application/json

{}
```

Sending to a resolved or closed conversation returns `422`; offer “Start new chat”.

## 7. Recommended mobile sequence

1. Log in and retain the borrower bearer token securely.
2. Load `GET /support/conversations` for the initial screen.
3. Load `GET /support/realtime` and initialize a Pusher-protocol client with its
   key, host, port, scheme, and optional path.
4. Set a custom private-channel authorizer that posts `socket_id` and
   `channel_name` to `auth_endpoint` with the bearer token.
5. Subscribe to `private_channel` and listen for `event`.
6. Upsert incoming messages by server `id`. Refetch when the app reconnects or when
   a sequence looks incomplete.
7. Send messages through REST only. Do not publish client events directly over the
   socket.
8. Disconnect on logout and reconnect when the app returns to the foreground.
9. While disconnected, poll the open conversation every five seconds. While
   connected, a 30–60 second reconciliation refresh is enough.

The chosen mobile WebSocket package must support the Pusher Channels protocol,
custom `wsHost`/ports, TLS, private channels, and a custom authorizer. This works
with Flutter, React Native, native Android, and native iOS clients without using a
Laravel-specific mobile SDK.

### Flutter authorizer example (Dio)

Wire this callback into the private-channel authorizer exposed by the selected
Pusher-compatible Flutter package:

```dart
Future<Map<String, dynamic>> authorizeSupportChannel({
  required Dio dio,
  required String authEndpoint,
  required String bearerToken,
  required String socketId,
  required String channelName,
}) async {
  final response = await dio.post<Map<String, dynamic>>(
    authEndpoint,
    data: {
      'socket_id': socketId,
      'channel_name': channelName,
    },
    options: Options(
      contentType: Headers.formUrlEncodedContentType,
      headers: {
        'Accept': 'application/json',
        'Authorization': 'Bearer $bearerToken',
      },
    ),
  );

  return response.data!;
}
```

## 8. Failure handling

| Condition | Action |
| --- | --- |
| Discovery `401` | Refresh login/session; do not connect anonymously |
| Discovery says `enabled: false` | Continue REST polling and retry later |
| Channel auth `403` | Leave the channel and refetch discovery; do not guess IDs |
| Socket disconnect | Show reconnecting state; keep queued UI messages and use REST retries |
| REST send timeout | Retry with the same `client_message_id` |
| Event duplicated | Ignore/upsert by message `id` |
| Event gap after backgrounding | Refetch conversation and list |

## 9. Server deployment checklist

Realtime is available only when all three processes are running:

```bash
php artisan queue:work --tries=3
php artisan reverb:start --host=0.0.0.0 --port=8080
php artisan schedule:work
```

Production must set `BROADCAST_CONNECTION=reverb`, unique
`REVERB_APP_ID`/`REVERB_APP_KEY`/`REVERB_APP_SECRET`, the externally reachable
`REVERB_HOST`, `REVERB_PORT=443`, `REVERB_SCHEME=https`, and an explicit comma-separated
`REVERB_ALLOWED_ORIGINS`. Run the queue worker and Reverb under Supervisor/systemd,
proxy WebSocket upgrade requests to port 8080, and restart both after deployment.

## 10. Image attachments

See [Mobile support chat: add image sending](mobile-support-chat-images.md) for the
complete mobile changes, upload contract, limits, retries, and expiry behavior.
