docs: add package documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 10:47:51 +00:00
parent 1da124a92a
commit 919f7e1fc1
9 changed files with 5288 additions and 0 deletions

391
docs/authentication.md Normal file
View file

@ -0,0 +1,391 @@
# API Authentication
The API package provides secure authentication with bcrypt-hashed API keys, scope-based permissions, and automatic key rotation.
## API Key Management
### Creating Keys
```php
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::create([
'name' => 'Mobile App Production',
'workspace_id' => $workspace->id,
'scopes' => ['posts:read', 'posts:write', 'categories:read'],
'rate_limit_tier' => 'pro',
'expires_at' => now()->addYear(),
]);
// Get plaintext key (only available once!)
$plaintext = $apiKey->plaintext_key;
// Returns: sk_live_abc123def456...
```
**Key Format:** `{prefix}_{environment}_{random}`
- Prefix: `sk` (secret key)
- Environment: `live` or `test`
- Random: 32-character string
### Secure Storage
Keys are hashed with bcrypt before storage:
```php
// Never stored in plaintext
$hash = bcrypt($plaintext);
// Stored in database
$apiKey->key_hash = $hash;
// Verification
if (Hash::check($providedKey, $apiKey->key_hash)) {
// Valid key
}
```
### Key Rotation
Rotate keys with a grace period:
```php
$newKey = $apiKey->rotate([
'grace_period_hours' => 24,
]);
// Returns new ApiKey with:
// - New plaintext key
// - Same scopes and settings
// - Old key marked for deletion after grace period
```
During the grace period, both keys work. After 24 hours, the old key is automatically deleted.
## Using API Keys
### Authorization Header
```bash
curl -H "Authorization: Bearer sk_live_abc123..." \
https://api.example.com/v1/posts
```
### Basic Auth
```bash
curl -u sk_live_abc123: \
https://api.example.com/v1/posts
```
### PHP Example
```php
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.example.com',
'headers' => [
'Authorization' => "Bearer {$apiKey}",
'Accept' => 'application/json',
],
]);
$response = $client->get('/v1/posts');
```
### JavaScript Example
```javascript
const response = await fetch('https://api.example.com/v1/posts', {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Accept': 'application/json'
}
});
```
## Scopes & Permissions
### Defining Scopes
```php
$apiKey = ApiKey::create([
'scopes' => [
'posts:read', // Read posts
'posts:write', // Create/update posts
'posts:delete', // Delete posts
'categories:read', // Read categories
],
]);
```
### Common Scopes
| Scope | Description |
|-------|-------------|
| `{resource}:read` | Read access |
| `{resource}:write` | Create and update |
| `{resource}:delete` | Delete access |
| `{resource}:*` | All permissions for resource |
| `*` | Full access (use sparingly!) |
### Wildcard Scopes
```php
// All post permissions
'scopes' => ['posts:*']
// Read access to all resources
'scopes' => ['*:read']
// Full access (admin only!)
'scopes' => ['*']
```
### Scope Enforcement
Protect routes with scope middleware:
```php
Route::middleware('scope:posts:write')
->post('/posts', [PostController::class, 'store']);
Route::middleware('scope:posts:delete')
->delete('/posts/{id}', [PostController::class, 'destroy']);
```
### Check Scopes in Controllers
```php
public function store(Request $request)
{
if (!$request->user()->tokenCan('posts:write')) {
return response()->json([
'error' => 'Insufficient permissions',
'required_scope' => 'posts:write',
], 403);
}
return Post::create($request->validated());
}
```
## Rate Limiting
Keys are rate-limited based on tier:
```php
// config/api.php
'rate_limits' => [
'free' => ['requests' => 1000, 'per' => 'hour'],
'pro' => ['requests' => 10000, 'per' => 'hour'],
'business' => ['requests' => 50000, 'per' => 'hour'],
'enterprise' => ['requests' => null], // Unlimited
],
```
Rate limit headers included in responses:
```
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9847
X-RateLimit-Reset: 1640995200
```
[Learn more about Rate Limiting →](/packages/api/rate-limiting)
## Key Expiration
### Set Expiration
```php
$apiKey = ApiKey::create([
'expires_at' => now()->addMonths(6),
]);
```
### Check Expiration
```php
if ($apiKey->isExpired()) {
return response()->json(['error' => 'API key expired'], 401);
}
```
### Auto-Cleanup
Expired keys are automatically cleaned up:
```bash
php artisan api:prune-expired-keys
```
## Environment-Specific Keys
### Test Keys
```php
$testKey = ApiKey::create([
'name' => 'Development Key',
'environment' => 'test',
]);
// Key prefix: sk_test_...
```
Test keys:
- Don't affect production data
- Higher rate limits
- Clearly marked in UI
- Easy to identify and delete
### Live Keys
```php
$liveKey = ApiKey::create([
'environment' => 'live',
]);
// Key prefix: sk_live_...
```
## Middleware
### API Authentication
```php
Route::middleware('auth:api')->group(function () {
// Protected routes
});
```
### Scope Enforcement
```php
use Mod\Api\Middleware\EnforceApiScope;
Route::middleware([EnforceApiScope::class.':posts:write'])
->post('/posts', [PostController::class, 'store']);
```
### Rate Limiting
```php
use Mod\Api\Middleware\RateLimitApi;
Route::middleware(RateLimitApi::class)->group(function () {
// Rate-limited routes
});
```
## Security Best Practices
### 1. Minimum Required Scopes
```php
// ✅ Good - specific scopes
'scopes' => ['posts:read', 'categories:read']
// ❌ Bad - excessive permissions
'scopes' => ['*']
```
### 2. Rotate Regularly
```php
// Rotate every 90 days
if ($apiKey->created_at->diffInDays() > 90) {
$newKey = $apiKey->rotate();
// Notify user of new key
}
```
### 3. Use Separate Keys Per Client
```php
// ✅ Good - separate keys
ApiKey::create(['name' => 'iOS App']);
ApiKey::create(['name' => 'Android App']);
ApiKey::create(['name' => 'Web App']);
// ❌ Bad - shared key
ApiKey::create(['name' => 'All Mobile Apps']);
```
### 4. Set Expiration
```php
// ✅ Good - temporary access
'expires_at' => now()->addMonths(6)
// ❌ Bad - never expires
'expires_at' => null
```
### 5. Monitor Usage
```php
$usage = ApiKey::find($id)->usage()
->whereBetween('created_at', [now()->subDays(7), now()])
->count();
if ($usage > $threshold) {
// Alert admin
}
```
## Testing
```php
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Mod\Api\Models\ApiKey;
class ApiKeyAuthTest extends TestCase
{
public function test_authenticates_with_valid_key(): void
{
$apiKey = ApiKey::factory()->create([
'scopes' => ['posts:read'],
]);
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->getJson('/api/v1/posts');
$response->assertOk();
}
public function test_rejects_invalid_key(): void
{
$response = $this->withHeaders([
'Authorization' => 'Bearer invalid_key',
])->getJson('/api/v1/posts');
$response->assertUnauthorized();
}
public function test_enforces_scopes(): void
{
$apiKey = ApiKey::factory()->create([
'scopes' => ['posts:read'], // No write permission
]);
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->postJson('/api/v1/posts', ['title' => 'Test']);
$response->assertForbidden();
}
}
```
## Learn More
- [Rate Limiting →](/packages/api/rate-limiting)
- [Scopes →](/packages/api/scopes)
- [Webhooks →](/packages/api/webhooks)
- [API Reference →](/api/authentication)

898
docs/building-rest-apis.md Normal file
View file

@ -0,0 +1,898 @@
# Building REST APIs
This guide covers how to build production-ready REST APIs using the core-api package. You'll learn to create resources, implement pagination, add filtering and sorting, and secure endpoints with authentication.
## Quick Start
Register API routes by listening to the `ApiRoutesRegistering` event:
```php
<?php
namespace Mod\Blog;
use Core\Events\ApiRoutesRegistering;
class Boot
{
public static array $listens = [
ApiRoutesRegistering::class => 'onApiRoutes',
];
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->routes(function () {
Route::apiResource('posts', Api\PostController::class);
});
}
}
```
## Creating Resources
### API Resources
Transform Eloquent models into consistent JSON responses using Laravel's API Resources:
```php
<?php
namespace Mod\Blog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => 'post',
'attributes' => [
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->when(
$request->user()?->tokenCan('posts:read-content'),
$this->content
),
'status' => $this->status,
'published_at' => $this->published_at?->toIso8601String(),
],
'relationships' => [
'author' => $this->whenLoaded('author', fn () => [
'id' => $this->author->id,
'name' => $this->author->name,
]),
'categories' => $this->whenLoaded('categories', fn () =>
$this->categories->map(fn ($cat) => [
'id' => $cat->id,
'name' => $cat->name,
])
),
],
'meta' => [
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
],
];
}
}
```
### Resource Controllers
Build controllers that use the `HasApiResponses` trait for consistent error handling:
```php
<?php
namespace Mod\Blog\Api;
use App\Http\Controllers\Controller;
use Core\Mod\Api\Concerns\HasApiResponses;
use Core\Mod\Api\Resources\PaginatedCollection;
use Illuminate\Http\Request;
use Mod\Blog\Models\Post;
use Mod\Blog\Resources\PostResource;
class PostController extends Controller
{
use HasApiResponses;
public function index(Request $request)
{
$posts = Post::query()
->with(['author', 'categories'])
->paginate($request->input('per_page', 25));
return new PaginatedCollection($posts, PostResource::class);
}
public function show(Post $post)
{
$post->load(['author', 'categories']);
return new PostResource($post);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'status' => 'in:draft,published',
]);
$post = Post::create($validated);
return $this->createdResponse(
new PostResource($post),
'Post created successfully.'
);
}
public function update(Request $request, Post $post)
{
$validated = $request->validate([
'title' => 'string|max:255',
'content' => 'string',
'status' => 'in:draft,published',
]);
$post->update($validated);
return new PostResource($post);
}
public function destroy(Post $post)
{
$post->delete();
return response()->json(null, 204);
}
}
```
## Pagination
### Using PaginatedCollection
The `PaginatedCollection` class provides standardized pagination metadata:
```php
use Core\Mod\Api\Resources\PaginatedCollection;
public function index(Request $request)
{
$posts = Post::paginate(
$request->input('per_page', config('api.pagination.default_per_page', 25))
);
return new PaginatedCollection($posts, PostResource::class);
}
```
### Response Format
Paginated responses include comprehensive metadata:
```json
{
"data": [
{"id": 1, "type": "post", "attributes": {...}},
{"id": 2, "type": "post", "attributes": {...}}
],
"meta": {
"current_page": 1,
"from": 1,
"last_page": 10,
"per_page": 25,
"to": 25,
"total": 250
},
"links": {
"first": "https://api.example.com/v1/posts?page=1",
"last": "https://api.example.com/v1/posts?page=10",
"prev": null,
"next": "https://api.example.com/v1/posts?page=2"
}
}
```
### Pagination Best Practices
**1. Limit Maximum Page Size**
```php
public function index(Request $request)
{
$perPage = min(
$request->input('per_page', 25),
config('api.pagination.max_per_page', 100)
);
return new PaginatedCollection(
Post::paginate($perPage),
PostResource::class
);
}
```
**2. Use Cursor Pagination for Large Datasets**
```php
public function index(Request $request)
{
$posts = Post::orderBy('id')
->cursorPaginate($request->input('per_page', 25));
return PostResource::collection($posts);
}
```
**3. Include Total Count Conditionally**
For very large tables, counting can be expensive:
```php
public function index(Request $request)
{
$query = Post::query();
// Only count if explicitly requested
if ($request->boolean('include_total')) {
return new PaginatedCollection(
$query->paginate($request->input('per_page', 25)),
PostResource::class
);
}
// Use simple pagination (no total count)
return PostResource::collection(
$query->simplePaginate($request->input('per_page', 25))
);
}
```
## Filtering
### Query Parameter Filters
Implement flexible filtering with query parameters:
```php
public function index(Request $request)
{
$query = Post::query();
// Status filter
if ($status = $request->input('status')) {
$query->where('status', $status);
}
// Date range filters
if ($after = $request->input('created_after')) {
$query->where('created_at', '>=', $after);
}
if ($before = $request->input('created_before')) {
$query->where('created_at', '<=', $before);
}
// Author filter
if ($authorId = $request->input('author_id')) {
$query->where('author_id', $authorId);
}
// Full-text search
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%");
});
}
return new PaginatedCollection(
$query->paginate($request->input('per_page', 25)),
PostResource::class
);
}
```
### Filter Validation
Validate filter parameters to prevent errors:
```php
public function index(Request $request)
{
$request->validate([
'status' => 'in:draft,published,archived',
'created_after' => 'date|before_or_equal:created_before',
'created_before' => 'date',
'author_id' => 'integer|exists:users,id',
'per_page' => 'integer|min:1|max:100',
]);
// Apply filters...
}
```
### Reusable Filter Traits
Create a trait for common filtering patterns:
```php
<?php
namespace Mod\Blog\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
trait FiltersQueries
{
protected function applyFilters(Builder $query, Request $request): Builder
{
// Date filters
if ($after = $request->input('created_after')) {
$query->where('created_at', '>=', $after);
}
if ($before = $request->input('created_before')) {
$query->where('created_at', '<=', $before);
}
if ($updatedAfter = $request->input('updated_after')) {
$query->where('updated_at', '>=', $updatedAfter);
}
// Status filter (if model has status)
if ($status = $request->input('status')) {
$query->where('status', $status);
}
return $query;
}
}
```
## Sorting
### Sort Parameter
Implement sorting with a `sort` query parameter:
```php
public function index(Request $request)
{
$query = Post::query();
// Parse sort parameter: -created_at,title
$sortFields = $this->parseSortFields(
$request->input('sort', '-created_at')
);
foreach ($sortFields as $field => $direction) {
$query->orderBy($field, $direction);
}
return new PaginatedCollection(
$query->paginate($request->input('per_page', 25)),
PostResource::class
);
}
protected function parseSortFields(string $sort): array
{
$allowedFields = ['id', 'title', 'created_at', 'updated_at', 'published_at'];
$fields = [];
foreach (explode(',', $sort) as $field) {
$direction = 'asc';
if (str_starts_with($field, '-')) {
$direction = 'desc';
$field = substr($field, 1);
}
if (in_array($field, $allowedFields)) {
$fields[$field] = $direction;
}
}
return $fields ?: ['created_at' => 'desc'];
}
```
### Sort Validation
Validate sort fields against an allowlist:
```php
public function index(Request $request)
{
$request->validate([
'sort' => [
'string',
'regex:/^-?(id|title|created_at|updated_at)(,-?(id|title|created_at|updated_at))*$/',
],
]);
// Apply sorting...
}
```
## Authentication
### Protecting Routes
Use the `auth:api` middleware to protect endpoints:
```php
// In your Boot class
$event->routes(function () {
// Public routes (no authentication)
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
// Protected routes (require authentication)
Route::middleware('auth:api')->group(function () {
Route::post('/posts', [PostController::class, 'store']);
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
});
```
### Scope-Based Authorization
Enforce API key scopes on routes:
```php
Route::middleware(['auth:api', 'scope:posts:write'])
->post('/posts', [PostController::class, 'store']);
Route::middleware(['auth:api', 'scope:posts:delete'])
->delete('/posts/{post}', [PostController::class, 'destroy']);
```
### Checking Scopes in Controllers
Verify scopes programmatically for fine-grained control:
```php
public function update(Request $request, Post $post)
{
// Check if user can update posts
if (!$request->user()->tokenCan('posts:write')) {
return $this->accessDeniedResponse('Insufficient permissions to update posts.');
}
// Check if user can publish (requires elevated scope)
if ($request->input('status') === 'published') {
if (!$request->user()->tokenCan('posts:publish')) {
return $this->accessDeniedResponse('Insufficient permissions to publish posts.');
}
}
$post->update($request->validated());
return new PostResource($post);
}
```
### API Key Authentication Examples
**PHP with Guzzle:**
```php
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.example.com/v1/',
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
]);
// List posts
$response = $client->get('posts', [
'query' => [
'status' => 'published',
'per_page' => 50,
'sort' => '-published_at',
],
]);
$posts = json_decode($response->getBody(), true);
// Create a post
$response = $client->post('posts', [
'json' => [
'title' => 'New Post',
'content' => 'Post content here...',
'status' => 'draft',
],
]);
$newPost = json_decode($response->getBody(), true);
```
**JavaScript with Fetch:**
```javascript
const API_KEY = 'sk_live_abc123...';
const BASE_URL = 'https://api.example.com/v1';
async function listPosts(params = {}) {
const query = new URLSearchParams(params).toString();
const response = await fetch(`${BASE_URL}/posts?${query}`, {
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
async function createPost(data) {
const response = await fetch(`${BASE_URL}/posts`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create post');
}
return response.json();
}
// Usage
const posts = await listPosts({ status: 'published', per_page: 25 });
const newPost = await createPost({ title: 'Hello', content: 'World' });
```
**Python with Requests:**
```python
import requests
API_KEY = 'sk_live_abc123...'
BASE_URL = 'https://api.example.com/v1'
headers = {
'Authorization': f'Bearer {API_KEY}',
'Accept': 'application/json',
'Content-Type': 'application/json',
}
# List posts
response = requests.get(
f'{BASE_URL}/posts',
headers=headers,
params={
'status': 'published',
'per_page': 50,
'sort': '-published_at',
}
)
response.raise_for_status()
posts = response.json()
# Create a post
response = requests.post(
f'{BASE_URL}/posts',
headers=headers,
json={
'title': 'New Post',
'content': 'Post content here...',
'status': 'draft',
}
)
response.raise_for_status()
new_post = response.json()
```
## OpenAPI Documentation
### Document Endpoints
Use attributes to auto-generate OpenAPI documentation:
```php
use Core\Mod\Api\Documentation\Attributes\ApiTag;
use Core\Mod\Api\Documentation\Attributes\ApiParameter;
use Core\Mod\Api\Documentation\Attributes\ApiResponse;
use Core\Mod\Api\Documentation\Attributes\ApiSecurity;
#[ApiTag('Posts', 'Blog post management')]
#[ApiSecurity('api_key')]
class PostController extends Controller
{
#[ApiParameter('page', 'query', 'integer', 'Page number', example: 1)]
#[ApiParameter('per_page', 'query', 'integer', 'Items per page', example: 25)]
#[ApiParameter('status', 'query', 'string', 'Filter by status', enum: ['draft', 'published'])]
#[ApiParameter('sort', 'query', 'string', 'Sort fields (prefix with - for desc)', example: '-created_at')]
#[ApiResponse(200, PostResource::class, 'List of posts', paginated: true)]
public function index(Request $request)
{
// ...
}
#[ApiParameter('id', 'path', 'integer', 'Post ID', required: true)]
#[ApiResponse(200, PostResource::class, 'Post details')]
#[ApiResponse(404, null, 'Post not found')]
public function show(Post $post)
{
// ...
}
#[ApiResponse(201, PostResource::class, 'Post created')]
#[ApiResponse(422, null, 'Validation error')]
public function store(Request $request)
{
// ...
}
}
```
## Error Handling
### Consistent Error Responses
Use the `HasApiResponses` trait for consistent errors:
```php
use Core\Mod\Api\Concerns\HasApiResponses;
class PostController extends Controller
{
use HasApiResponses;
public function show($id)
{
$post = Post::find($id);
if (!$post) {
return $this->notFoundResponse('Post');
}
return new PostResource($post);
}
public function store(Request $request)
{
// Check entitlement limits
if (!$this->canCreatePost($request->user())) {
return $this->limitReachedResponse(
'posts',
'You have reached your post limit. Please upgrade your plan.'
);
}
// Validation errors are handled automatically by Laravel
$validated = $request->validate([...]);
// ...
}
}
```
### Error Response Format
All errors follow a consistent format:
```json
{
"error": "not_found",
"message": "Post not found."
}
```
```json
{
"error": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"title": ["The title field is required."],
"content": ["The content must be at least 100 characters."]
}
}
```
```json
{
"error": "feature_limit_reached",
"message": "You have reached your post limit.",
"feature": "posts",
"upgrade_url": "https://example.com/upgrade"
}
```
## Best Practices
### 1. Use API Resources
Always transform models through resources:
```php
// Good - consistent response format
return new PostResource($post);
// Bad - exposes database schema
return response()->json($post);
```
### 2. Validate All Input
```php
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string|min:100',
'status' => 'in:draft,published',
'published_at' => 'nullable|date|after:now',
]);
// Use validated data only
$post = Post::create($validated);
}
```
### 3. Eager Load Relationships
```php
// Good - single query with eager loading
$posts = Post::with(['author', 'categories'])->paginate();
// Bad - N+1 queries
$posts = Post::paginate();
foreach ($posts as $post) {
echo $post->author->name; // Additional query per post
}
```
### 4. Use Route Model Binding
```php
// Good - automatic 404 if not found
public function show(Post $post)
{
return new PostResource($post);
}
// Unnecessary - route model binding handles this
public function show($id)
{
$post = Post::findOrFail($id);
return new PostResource($post);
}
```
### 5. Scope Data by Workspace
```php
public function index(Request $request)
{
$workspaceId = $request->user()->currentWorkspaceId();
$posts = Post::where('workspace_id', $workspaceId)
->paginate();
return new PaginatedCollection($posts, PostResource::class);
}
```
## Testing
### Feature Tests
```php
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Mod\Api\Models\ApiKey;
use Mod\Blog\Models\Post;
class PostApiTest extends TestCase
{
public function test_lists_posts(): void
{
$apiKey = ApiKey::factory()->create([
'scopes' => ['posts:read'],
]);
Post::factory()->count(5)->create();
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->getJson('/api/v1/posts');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'type', 'attributes'],
],
'meta' => ['current_page', 'total'],
'links',
]);
}
public function test_filters_posts_by_status(): void
{
$apiKey = ApiKey::factory()->create(['scopes' => ['posts:read']]);
Post::factory()->create(['status' => 'draft']);
Post::factory()->create(['status' => 'published']);
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->getJson('/api/v1/posts?status=published');
$response->assertOk()
->assertJsonCount(1, 'data');
}
public function test_creates_post_with_valid_scope(): void
{
$apiKey = ApiKey::factory()->create([
'scopes' => ['posts:write'],
]);
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->postJson('/api/v1/posts', [
'title' => 'Test Post',
'content' => 'Test content...',
]);
$response->assertCreated()
->assertJsonPath('data.attributes.title', 'Test Post');
}
public function test_rejects_create_without_scope(): void
{
$apiKey = ApiKey::factory()->create([
'scopes' => ['posts:read'], // No write scope
]);
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->postJson('/api/v1/posts', [
'title' => 'Test Post',
'content' => 'Test content...',
]);
$response->assertForbidden();
}
}
```
## Learn More
- [Authentication](/packages/api/authentication) - API key management
- [Rate Limiting](/packages/api/rate-limiting) - Tier-based rate limits
- [Scopes](/packages/api/scopes) - Permission system
- [Webhooks](/packages/api/webhooks) - Event notifications
- [OpenAPI Documentation](/packages/api/documentation) - Auto-generated docs

474
docs/documentation.md Normal file
View file

@ -0,0 +1,474 @@
# API Documentation
Automatically generate OpenAPI 3.0 documentation with Swagger UI, Scalar, and ReDoc viewers.
## Overview
The API package automatically generates OpenAPI documentation from your routes, controllers, and doc blocks.
**Features:**
- Automatic route discovery
- OpenAPI 3.0 spec generation
- Multiple documentation viewers
- Security scheme documentation
- Request/response examples
- Interactive API explorer
## Accessing Documentation
### Available Endpoints
```
/api/docs - Swagger UI (default)
/api/docs/scalar - Scalar viewer
/api/docs/redoc - ReDoc viewer
/api/docs/openapi - Raw OpenAPI JSON
```
### Protection
Documentation is protected in production:
```php
// config/api.php
return [
'documentation' => [
'enabled' => env('API_DOCS_ENABLED', !app()->isProduction()),
'middleware' => ['auth', 'can:view-api-docs'],
],
];
```
## Attributes
### Hiding Endpoints
```php
use Mod\Api\Documentation\Attributes\ApiHidden;
#[ApiHidden]
class InternalController
{
// Entire controller hidden from docs
}
class PostController
{
#[ApiHidden]
public function internalMethod()
{
// Single method hidden
}
}
```
### Tagging Endpoints
```php
use Mod\Api\Documentation\Attributes\ApiTag;
#[ApiTag('Blog Posts')]
class PostController
{
// All methods tagged with "Blog Posts"
}
```
### Documenting Parameters
```php
use Mod\Api\Documentation\Attributes\ApiParameter;
class PostController
{
#[ApiParameter(
name: 'status',
in: 'query',
description: 'Filter by post status',
required: false,
schema: ['type' => 'string', 'enum' => ['draft', 'published', 'archived']]
)]
#[ApiParameter(
name: 'category',
in: 'query',
description: 'Filter by category ID',
schema: ['type' => 'integer']
)]
public function index(Request $request)
{
// GET /posts?status=published&category=5
}
}
```
### Documenting Responses
```php
use Mod\Api\Documentation\Attributes\ApiResponse;
class PostController
{
#[ApiResponse(
status: 200,
description: 'Post created successfully',
content: [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'status' => ['type' => 'string'],
],
],
],
]
)]
#[ApiResponse(
status: 422,
description: 'Validation error'
)]
public function store(Request $request)
{
// ...
}
}
```
### Security Requirements
```php
use Mod\Api\Documentation\Attributes\ApiSecurity;
#[ApiSecurity(['apiKey' => []])]
class PostController
{
// Requires API key authentication
}
#[ApiSecurity(['bearerAuth' => ['posts:write']])]
public function store(Request $request)
{
// Requires Bearer token with posts:write scope
}
```
## Configuration
```php
// config/api.php
return [
'documentation' => [
'enabled' => true,
'info' => [
'title' => 'Core PHP Framework API',
'description' => 'REST API for Core PHP Framework',
'version' => '1.0.0',
'contact' => [
'name' => 'API Support',
'email' => 'api@example.com',
'url' => 'https://example.com/support',
],
],
'servers' => [
[
'url' => 'https://api.example.com',
'description' => 'Production',
],
[
'url' => 'https://staging.example.com',
'description' => 'Staging',
],
],
'security_schemes' => [
'apiKey' => [
'type' => 'http',
'scheme' => 'bearer',
'bearerFormat' => 'API Key',
'description' => 'API key authentication. Format: `Bearer sk_live_...`',
],
],
'viewers' => [
'swagger' => true,
'scalar' => true,
'redoc' => true,
],
],
];
```
## Extensions
### Custom Extensions
```php
<?php
namespace Mod\Blog\Api\Documentation;
use Mod\Api\Documentation\Extension;
class BlogExtension extends Extension
{
public function apply(array $spec): array
{
// Add custom tags
$spec['tags'][] = [
'name' => 'Blog Posts',
'description' => 'Operations for managing blog posts',
];
// Add custom security requirements
$spec['paths']['/posts']['post']['security'][] = [
'apiKey' => [],
];
return $spec;
}
}
```
**Register Extension:**
```php
use Core\Events\ApiRoutesRegistering;
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->documentationExtension(new BlogExtension());
}
```
### Built-in Extensions
**Rate Limit Extension:**
```php
use Mod\Api\Documentation\Extensions\RateLimitExtension;
// Automatically documents rate limits in responses
// Adds X-RateLimit-* headers to all endpoints
```
**Workspace Header Extension:**
```php
use Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension;
// Documents X-Workspace-ID header requirement
// Adds to all workspace-scoped endpoints
```
## Common Examples
### Pagination
```php
use Mod\Api\Documentation\Examples\CommonExamples;
#[ApiResponse(
status: 200,
description: 'Paginated list of posts',
content: CommonExamples::paginatedResponse('posts', [
'id' => 1,
'title' => 'Example Post',
'status' => 'published',
])
)]
public function index(Request $request)
{
return PostResource::collection(
Post::paginate(20)
);
}
```
**Generates:**
```json
{
"data": [
{
"id": 1,
"title": "Example Post",
"status": "published"
}
],
"links": {
"first": "...",
"last": "...",
"prev": null,
"next": "..."
},
"meta": {
"current_page": 1,
"total": 100
}
}
```
### Error Responses
```php
#[ApiResponse(
status: 404,
description: 'Post not found',
content: CommonExamples::errorResponse('Post not found', 'resource_not_found')
)]
public function show(Post $post)
{
return new PostResource($post);
}
```
## Module Discovery
The documentation system automatically discovers API routes from all modules:
```php
// Mod\Blog\Boot
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->routes(function () {
Route::get('/posts', [PostController::class, 'index']);
// Automatically included in docs
});
}
```
**Discovery Process:**
1. Scan all registered API routes
2. Extract controller methods
3. Parse doc blocks and attributes
4. Generate OpenAPI spec
5. Cache for performance
## Viewers
### Swagger UI
Interactive API explorer with "Try it out" functionality.
**Access:** `/api/docs`
**Features:**
- Test endpoints directly
- View request/response examples
- OAuth/API key authentication
- Model schemas
### Scalar
Modern, clean documentation viewer.
**Access:** `/api/docs/scalar`
**Features:**
- Beautiful UI
- Dark mode
- Code examples in multiple languages
- Interactive examples
### ReDoc
Professional documentation with three-panel layout.
**Access:** `/api/docs/redoc`
**Features:**
- Search functionality
- Menu navigation
- Responsive design
- Printable
## Best Practices
### 1. Document All Public Endpoints
```php
// ✅ Good - documented
#[ApiTag('Posts')]
#[ApiResponse(200, 'Success')]
#[ApiResponse(422, 'Validation error')]
public function store(Request $request)
// ❌ Bad - undocumented
public function store(Request $request)
```
### 2. Provide Examples
```php
// ✅ Good - request example
#[ApiParameter(
name: 'status',
example: 'published'
)]
// ❌ Bad - no example
#[ApiParameter(name: 'status')]
```
### 3. Hide Internal Endpoints
```php
// ✅ Good - hidden
#[ApiHidden]
public function internal()
// ❌ Bad - exposed in docs
public function internal()
```
### 4. Group Related Endpoints
```php
// ✅ Good - tagged
#[ApiTag('Blog Posts')]
class PostController
// ❌ Bad - ungrouped
class PostController
```
## Testing
```php
use Tests\TestCase;
class DocumentationTest extends TestCase
{
public function test_generates_openapi_spec(): void
{
$response = $this->getJson('/api/docs/openapi');
$response->assertStatus(200);
$response->assertJsonStructure([
'openapi',
'info',
'paths',
'components',
]);
}
public function test_includes_blog_endpoints(): void
{
$response = $this->getJson('/api/docs/openapi');
$spec = $response->json();
$this->assertArrayHasKey('/posts', $spec['paths']);
$this->assertArrayHasKey('/posts/{id}', $spec['paths']);
}
}
```
## Learn More
- [Authentication →](/packages/api/authentication)
- [Scopes →](/packages/api/scopes)
- [API Reference →](/api/endpoints)

1129
docs/endpoints-reference.md Normal file

File diff suppressed because it is too large Load diff

338
docs/index.md Normal file
View file

@ -0,0 +1,338 @@
# API Package
The API package provides a complete REST API with secure authentication, rate limiting, webhooks, and OpenAPI documentation.
## Installation
```bash
composer require host-uk/core-api
```
## Quick Start
```php
<?php
namespace Mod\Blog;
use Core\Events\ApiRoutesRegistering;
class Boot
{
public static array $listens = [
ApiRoutesRegistering::class => 'onApiRoutes',
];
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->routes(function () {
Route::get('/posts', [Api\PostController::class, 'index']);
Route::post('/posts', [Api\PostController::class, 'store']);
Route::get('/posts/{id}', [Api\PostController::class, 'show']);
});
}
}
```
## Key Features
### Authentication & Security
- **[API Keys](/packages/api/authentication)** - Secure API key management with bcrypt hashing
- **[Scopes](/packages/api/scopes)** - Fine-grained permission system
- **[Rate Limiting](/packages/api/rate-limiting)** - Tier-based rate limits with Redis backend
- **[Key Rotation](/packages/api/authentication#rotation)** - Secure key rotation with grace periods
### Webhooks
- **[Webhook Endpoints](/packages/api/webhooks)** - Event-driven notifications
- **[Signatures](/packages/api/webhooks#signatures)** - HMAC-SHA256 signature verification
- **[Delivery Tracking](/packages/api/webhooks#delivery)** - Retry logic and delivery history
### Documentation
- **[OpenAPI Spec](/packages/api/openapi)** - Auto-generated OpenAPI 3.0 documentation
- **[Interactive Docs](/packages/api/documentation)** - Swagger UI, Scalar, and ReDoc interfaces
- **[Code Examples](/packages/api/documentation#examples)** - Multi-language code snippets
### Monitoring
- **[Usage Analytics](/packages/api/analytics)** - Track API usage and quota
- **[Usage Alerts](/packages/api/alerts)** - Automated high-usage notifications
- **[Request Logging](/packages/api/logging)** - Comprehensive request/response logging
## Authentication
### Creating API Keys
```php
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::create([
'name' => 'Mobile App',
'workspace_id' => $workspace->id,
'scopes' => ['posts:read', 'posts:write'],
'rate_limit_tier' => 'pro',
]);
// Get plaintext key (only shown once!)
$plaintext = $apiKey->plaintext_key; // sk_live_abc123...
```
### Using API Keys
```bash
curl -H "Authorization: Bearer sk_live_abc123..." \
https://api.example.com/v1/posts
```
[Learn more about Authentication →](/packages/api/authentication)
## Rate Limiting
Tier-based rate limits with automatic enforcement:
```php
// config/api.php
'rate_limits' => [
'free' => ['requests' => 1000, 'per' => 'hour'],
'pro' => ['requests' => 10000, 'per' => 'hour'],
'business' => ['requests' => 50000, 'per' => 'hour'],
'enterprise' => ['requests' => null], // Unlimited
],
```
Rate limit headers included in every response:
```
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9847
X-RateLimit-Reset: 1640995200
```
[Learn more about Rate Limiting →](/packages/api/rate-limiting)
## Webhooks
### Creating Webhooks
```php
use Mod\Api\Models\WebhookEndpoint;
$webhook = WebhookEndpoint::create([
'url' => 'https://your-app.com/webhooks',
'events' => ['post.created', 'post.updated'],
'secret' => 'whsec_abc123...',
'workspace_id' => $workspace->id,
]);
```
### Dispatching Events
```php
use Mod\Api\Services\WebhookService;
$service = app(WebhookService::class);
$service->dispatch('post.created', [
'id' => $post->id,
'title' => $post->title,
'url' => route('posts.show', $post),
]);
```
### Verifying Signatures
```php
use Mod\Api\Services\WebhookSignature;
$signature = WebhookSignature::verify(
payload: $request->getContent(),
signature: $request->header('X-Webhook-Signature'),
secret: $webhook->secret
);
if (!$signature) {
abort(401, 'Invalid signature');
}
```
[Learn more about Webhooks →](/packages/api/webhooks)
## OpenAPI Documentation
Auto-generate OpenAPI documentation with attributes:
```php
use Mod\Api\Documentation\Attributes\ApiTag;
use Mod\Api\Documentation\Attributes\ApiParameter;
use Mod\Api\Documentation\Attributes\ApiResponse;
#[ApiTag('Posts')]
class PostController extends Controller
{
#[ApiParameter(name: 'page', in: 'query', type: 'integer')]
#[ApiParameter(name: 'per_page', in: 'query', type: 'integer')]
#[ApiResponse(status: 200, description: 'List of posts')]
public function index(Request $request)
{
return PostResource::collection(
Post::paginate($request->input('per_page', 15))
);
}
}
```
View documentation at:
- `/api/docs` - Swagger UI
- `/api/docs/scalar` - Scalar interface
- `/api/docs/redoc` - ReDoc interface
[Learn more about Documentation →](/packages/api/documentation)
## API Resources
Transform models to JSON:
```php
<?php
namespace Mod\Blog\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->when(
$request->user()->tokenCan('posts:read-content'),
$this->content
),
'status' => $this->status,
'published_at' => $this->published_at?->toIso8601String(),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
```
## Configuration
```php
// config/api.php
return [
'prefix' => 'api/v1',
'middleware' => ['api'],
'rate_limits' => [
'free' => ['requests' => 1000, 'per' => 'hour'],
'pro' => ['requests' => 10000, 'per' => 'hour'],
'business' => ['requests' => 50000, 'per' => 'hour'],
'enterprise' => ['requests' => null],
],
'api_keys' => [
'hash_algo' => 'bcrypt',
'prefix' => 'sk',
'length' => 32,
],
'webhooks' => [
'max_retries' => 3,
'retry_delay' => 60, // seconds
'signature_algo' => 'sha256',
],
'documentation' => [
'enabled' => true,
'middleware' => ['web', 'auth'],
'title' => 'API Documentation',
],
];
```
## Best Practices
### 1. Use API Resources
```php
// ✅ Good - API resource
return PostResource::collection($posts);
// ❌ Bad - raw model data
return $posts->toArray();
```
### 2. Implement Scopes
```php
// ✅ Good - scope protection
Route::middleware('scope:posts:write')
->post('/posts', [PostController::class, 'store']);
```
### 3. Verify Webhook Signatures
```php
// ✅ Good - verify signature
if (!WebhookSignature::verify($payload, $signature, $secret)) {
abort(401);
}
```
### 4. Use Rate Limit Middleware
```php
// ✅ Good - rate limited
Route::middleware('api.rate-limit')
->group(function () {
// API routes
});
```
## Testing
```php
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Mod\Api\Models\ApiKey;
class PostApiTest extends TestCase
{
public function test_lists_posts(): void
{
$apiKey = ApiKey::factory()->create([
'scopes' => ['posts:read'],
]);
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->getJson('/api/v1/posts');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'slug'],
],
]);
}
}
```
## Learn More
- [Authentication →](/packages/api/authentication)
- [Rate Limiting →](/packages/api/rate-limiting)
- [Webhooks →](/packages/api/webhooks)
- [OpenAPI Docs →](/packages/api/documentation)
- [API Reference →](/api/endpoints)

246
docs/rate-limiting.md Normal file
View file

@ -0,0 +1,246 @@
# Rate Limiting
The API package provides tier-based rate limiting with Redis backend, custom limits per endpoint, and automatic enforcement.
## Overview
Rate limiting:
- Prevents API abuse
- Ensures fair usage
- Protects server resources
- Enforces tier limits
## Tier-Based Limits
Configure limits per tier:
```php
// config/api.php
'rate_limits' => [
'free' => [
'requests' => 1000,
'per' => 'hour',
],
'pro' => [
'requests' => 10000,
'per' => 'hour',
],
'business' => [
'requests' => 50000,
'per' => 'hour',
],
'enterprise' => [
'requests' => null, // Unlimited
],
],
```
## Response Headers
Every response includes rate limit headers:
```
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9847
X-RateLimit-Reset: 1640995200
```
## Applying Rate Limits
### Global Rate Limiting
```php
// Apply to all API routes
Route::middleware('api.rate-limit')->group(function () {
Route::get('/posts', [PostController::class, 'index']);
Route::post('/posts', [PostController::class, 'store']);
});
```
### Per-Endpoint Limits
```php
// Custom limit for specific endpoint
Route::get('/search', [SearchController::class, 'index'])
->middleware('throttle:60,1'); // 60 per minute
```
### Named Rate Limiters
```php
// app/Providers/RouteServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Apply in routes
Route::middleware('throttle:api')->group(function () {
// Routes
});
```
## Custom Rate Limiting
### Based on API Key Tier
```php
use Mod\Api\Services\RateLimitService;
$rateLimitService = app(RateLimitService::class);
$result = $rateLimitService->attempt($apiKey);
if ($result->exceeded()) {
return response()->json([
'error' => 'Rate limit exceeded',
'retry_after' => $result->retryAfter(),
], 429);
}
```
### Dynamic Limits
```php
RateLimiter::for('api', function (Request $request) {
$apiKey = $request->user()->currentApiKey();
return match ($apiKey->rate_limit_tier) {
'free' => Limit::perHour(1000),
'pro' => Limit::perHour(10000),
'business' => Limit::perHour(50000),
'enterprise' => Limit::none(),
};
});
```
## Rate Limit Responses
### 429 Too Many Requests
```json
{
"message": "Too many requests",
"error_code": "RATE_LIMIT_EXCEEDED",
"retry_after": 3600,
"limit": 10000,
"remaining": 0,
"reset_at": "2024-01-15T12:00:00Z"
}
```
### Retry-After Header
```
HTTP/1.1 429 Too Many Requests
Retry-After: 3600
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640995200
```
## Monitoring
### Check Current Usage
```php
use Mod\Api\Services\RateLimitService;
$service = app(RateLimitService::class);
$usage = $service->getCurrentUsage($apiKey);
echo "Used: {$usage->used} / {$usage->limit}";
echo "Remaining: {$usage->remaining}";
echo "Resets at: {$usage->reset_at}";
```
### Usage Analytics
```php
$apiKey = ApiKey::find($id);
$stats = $apiKey->usage()
->whereBetween('created_at', [now()->subDays(7), now()])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->get();
```
## Best Practices
### 1. Handle 429 Gracefully
```javascript
// ✅ Good - retry with backoff
async function apiRequest(url, retries = 3) {
for (let i = 0; i < retries; i++) {
const response = await fetch(url);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After'));
await sleep(retryAfter * 1000);
continue;
}
return response;
}
}
```
### 2. Respect Rate Limit Headers
```javascript
// ✅ Good - check remaining requests
const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
if (remaining < 10) {
console.warn('Approaching rate limit');
}
```
### 3. Implement Exponential Backoff
```javascript
// ✅ Good - exponential backoff
async function fetchWithBackoff(url, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
const response = await fetch(url);
if (response.status !== 429) {
return response;
}
const delay = Math.min(1000 * Math.pow(2, i), 30000);
await sleep(delay);
}
}
```
### 4. Use Caching
```javascript
// ✅ Good - cache responses
const cache = new Map();
async function fetchPost(id) {
const cached = cache.get(id);
if (cached && Date.now() - cached.timestamp < 60000) {
return cached.data;
}
const response = await fetch(`/api/v1/posts/${id}`);
const data = await response.json();
cache.set(id, {data, timestamp: Date.now()});
return data;
}
```
## Learn More
- [API Authentication →](/packages/api/authentication)
- [Error Handling →](/api/errors)
- [API Reference →](/api/endpoints#rate-limiting)

548
docs/scopes.md Normal file
View file

@ -0,0 +1,548 @@
# API Scopes
Fine-grained permission control for API keys using OAuth-style scopes.
## Scope Format
Scopes follow the format: `resource:action`
**Examples:**
- `posts:read` - Read blog posts
- `posts:write` - Create and update posts
- `posts:delete` - Delete posts
- `users:*` - All user operations
- `*:read` - Read access to all resources
- `*` - Full access (use sparingly!)
## Available Scopes
### Content Management
| Scope | Description |
|-------|-------------|
| `posts:read` | View published posts |
| `posts:write` | Create and update posts |
| `posts:delete` | Delete posts |
| `posts:publish` | Publish posts |
| `pages:read` | View static pages |
| `pages:write` | Create and update pages |
| `pages:delete` | Delete pages |
| `categories:read` | View categories |
| `categories:write` | Manage categories |
| `tags:read` | View tags |
| `tags:write` | Manage tags |
### User Management
| Scope | Description |
|-------|-------------|
| `users:read` | View user profiles |
| `users:write` | Update user profiles |
| `users:delete` | Delete users |
| `users:roles` | Manage user roles |
| `users:permissions` | Manage user permissions |
### Analytics
| Scope | Description |
|-------|-------------|
| `analytics:read` | View analytics data |
| `analytics:export` | Export analytics |
| `metrics:read` | View system metrics |
### Webhooks
| Scope | Description |
|-------|-------------|
| `webhooks:read` | View webhook endpoints |
| `webhooks:write` | Create and update webhooks |
| `webhooks:delete` | Delete webhooks |
| `webhooks:manage` | Full webhook management |
### API Keys
| Scope | Description |
|-------|-------------|
| `keys:read` | View API keys |
| `keys:write` | Create API keys |
| `keys:delete` | Delete API keys |
| `keys:manage` | Full key management |
### Workspace Management
| Scope | Description |
|-------|-------------|
| `workspace:read` | View workspace details |
| `workspace:write` | Update workspace settings |
| `workspace:members` | Manage workspace members |
| `workspace:billing` | Access billing information |
### Admin Operations
| Scope | Description |
|-------|-------------|
| `admin:users` | Admin user management |
| `admin:workspaces` | Admin workspace management |
| `admin:system` | System administration |
| `admin:*` | Full admin access |
## Assigning Scopes
### API Key Creation
```php
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::create([
'name' => 'Mobile App',
'workspace_id' => $workspace->id,
'scopes' => [
'posts:read',
'posts:write',
'categories:read',
],
]);
```
### Sanctum Tokens
```php
$user = User::find(1);
$token = $user->createToken('mobile-app', [
'posts:read',
'posts:write',
'analytics:read',
])->plainTextToken;
```
## Scope Enforcement
### Route Protection
```php
use Mod\Api\Middleware\EnforceApiScope;
// Single scope
Route::middleware(['auth:sanctum', 'scope:posts:write'])
->post('/posts', [PostController::class, 'store']);
// Multiple scopes (all required)
Route::middleware(['auth:sanctum', 'scopes:posts:write,categories:read'])
->post('/posts', [PostController::class, 'store']);
// Any scope (at least one required)
Route::middleware(['auth:sanctum', 'scope-any:posts:write,pages:write'])
->post('/content', [ContentController::class, 'store']);
```
### Controller Checks
```php
<?php
namespace Mod\Blog\Controllers\Api;
class PostController
{
public function store(Request $request)
{
// Check single scope
if (!$request->user()->tokenCan('posts:write')) {
abort(403, 'Insufficient permissions');
}
// Check multiple scopes
if (!$request->user()->tokenCan('posts:write') ||
!$request->user()->tokenCan('categories:read')) {
abort(403);
}
// Proceed with creation
$post = Post::create($request->validated());
return new PostResource($post);
}
public function publish(Post $post)
{
// Require specific scope for sensitive action
if (!request()->user()->tokenCan('posts:publish')) {
abort(403, 'Publishing requires posts:publish scope');
}
$post->publish();
return new PostResource($post);
}
}
```
## Wildcard Scopes
### Resource Wildcards
Grant all permissions for a resource:
```php
$apiKey->scopes = [
'posts:*', // All post operations
'categories:*', // All category operations
];
```
**Equivalent to:**
```php
$apiKey->scopes = [
'posts:read',
'posts:write',
'posts:delete',
'posts:publish',
'categories:read',
'categories:write',
'categories:delete',
];
```
### Action Wildcards
Grant read-only access to everything:
```php
$apiKey->scopes = [
'*:read', // Read access to all resources
];
```
### Full Access
```php
$apiKey->scopes = ['*']; // Full access (dangerous!)
```
::: warning
Only use `*` scope for admin integrations. Always prefer specific scopes.
:::
## Scope Validation
### Custom Scopes
Define custom scopes for your modules:
```php
<?php
namespace Mod\Shop\Api;
use Mod\Api\Contracts\ScopeProvider;
class ShopScopeProvider implements ScopeProvider
{
public function scopes(): array
{
return [
'products:read' => 'View products',
'products:write' => 'Create and update products',
'products:delete' => 'Delete products',
'orders:read' => 'View orders',
'orders:write' => 'Process orders',
'orders:refund' => 'Issue refunds',
];
}
}
```
**Register Provider:**
```php
use Core\Events\ApiRoutesRegistering;
use Mod\Shop\Api\ShopScopeProvider;
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->scopes(new ShopScopeProvider());
}
```
### Scope Groups
Group related scopes:
```php
// config/api.php
return [
'scope_groups' => [
'content_admin' => [
'posts:*',
'pages:*',
'categories:*',
'tags:*',
],
'analytics_viewer' => [
'analytics:read',
'metrics:read',
],
'webhook_manager' => [
'webhooks:*',
],
],
];
```
**Usage:**
```php
// Assign group instead of individual scopes
$apiKey->scopes = config('api.scope_groups.content_admin');
```
## Checking Scopes
### Token Abilities
```php
// Check if token has scope
if ($request->user()->tokenCan('posts:write')) {
// Has permission
}
// Check multiple scopes (all required)
if ($request->user()->tokenCan('posts:write') &&
$request->user()->tokenCan('posts:publish')) {
// Has both permissions
}
// Get all token abilities
$abilities = $request->user()->currentAccessToken()->abilities;
```
### Scope Middleware
```php
// Require single scope
Route::middleware('scope:posts:write')->post('/posts', ...);
// Require all scopes
Route::middleware('scopes:posts:write,categories:read')->post('/posts', ...);
// Require any scope (OR logic)
Route::middleware('scope-any:posts:write,pages:write')->post('/content', ...);
```
### API Key Scopes
```php
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::findByKey($providedKey);
// Check scope
if ($apiKey->hasScope('posts:write')) {
// Has permission
}
// Check multiple scopes
if ($apiKey->hasAllScopes(['posts:write', 'categories:read'])) {
// Has all permissions
}
// Check any scope
if ($apiKey->hasAnyScope(['posts:write', 'pages:write'])) {
// Has at least one permission
}
```
## Scope Inheritance
### Hierarchical Scopes
Higher-level scopes include lower-level scopes:
```
admin:* includes:
├─ admin:users
├─ admin:workspaces
└─ admin:system
workspace:* includes:
├─ workspace:read
├─ workspace:write
├─ workspace:members
└─ workspace:billing
```
**Implementation:**
```php
public function hasScope(string $scope): bool
{
// Exact match
if (in_array($scope, $this->scopes)) {
return true;
}
// Check wildcards
[$resource, $action] = explode(':', $scope);
// Resource wildcard (e.g., posts:*)
if (in_array("{$resource}:*", $this->scopes)) {
return true;
}
// Action wildcard (e.g., *:read)
if (in_array("*:{$action}", $this->scopes)) {
return true;
}
// Full wildcard
return in_array('*', $this->scopes);
}
```
## Error Responses
### Insufficient Scope
```json
{
"message": "Insufficient scope",
"required_scope": "posts:write",
"provided_scopes": ["posts:read"],
"error_code": "insufficient_scope"
}
```
**HTTP Status:** 403 Forbidden
### Missing Scope
```json
{
"message": "This action requires the 'posts:publish' scope",
"required_scope": "posts:publish",
"error_code": "scope_required"
}
```
## Best Practices
### 1. Principle of Least Privilege
```php
// ✅ Good - minimal scopes
$apiKey->scopes = [
'posts:read',
'categories:read',
];
// ❌ Bad - excessive permissions
$apiKey->scopes = ['*'];
```
### 2. Use Specific Scopes
```php
// ✅ Good - specific actions
$apiKey->scopes = [
'posts:read',
'posts:write',
];
// ❌ Bad - overly broad
$apiKey->scopes = ['posts:*'];
```
### 3. Document Required Scopes
```php
/**
* Publish a blog post.
*
* Required scopes:
* - posts:write (to modify post)
* - posts:publish (to change status)
*
* @requires posts:write
* @requires posts:publish
*/
public function publish(Post $post)
{
// ...
}
```
### 4. Validate Early
```php
// ✅ Good - check at route level
Route::middleware('scope:posts:write')
->post('/posts', [PostController::class, 'store']);
// ❌ Bad - check late in controller
public function store(Request $request)
{
$validated = $request->validate([...]); // Wasted work
if (!$request->user()->tokenCan('posts:write')) {
abort(403);
}
}
```
## Testing Scopes
```php
use Tests\TestCase;
use Laravel\Sanctum\Sanctum;
class ScopeTest extends TestCase
{
public function test_requires_write_scope(): void
{
$user = User::factory()->create();
// Token without write scope
Sanctum::actingAs($user, ['posts:read']);
$response = $this->postJson('/api/v1/posts', [
'title' => 'Test Post',
]);
$response->assertStatus(403);
}
public function test_allows_with_correct_scope(): void
{
$user = User::factory()->create();
// Token with write scope
Sanctum::actingAs($user, ['posts:write']);
$response = $this->postJson('/api/v1/posts', [
'title' => 'Test Post',
'content' => 'Content',
]);
$response->assertStatus(201);
}
public function test_wildcard_scope_grants_access(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:*']);
$this->postJson('/api/v1/posts', [...])->assertStatus(201);
$this->putJson('/api/v1/posts/1', [...])->assertStatus(200);
$this->deleteJson('/api/v1/posts/1')->assertStatus(204);
}
}
```
## Learn More
- [Authentication →](/packages/api/authentication)
- [Rate Limiting →](/packages/api/rate-limiting)
- [API Reference →](/api/authentication)

765
docs/webhook-integration.md Normal file
View file

@ -0,0 +1,765 @@
# Webhook Integration Guide
This guide explains how to receive and process webhooks from the core-api package. Learn to verify signatures, handle retries, and implement reliable webhook consumers.
## Overview
Webhooks provide real-time notifications when events occur in the system. Instead of polling the API, your application receives HTTP POST requests with event data.
**Key Features:**
- HMAC-SHA256 signature verification
- Automatic retries with exponential backoff
- Timestamp validation for replay protection
- Delivery tracking and manual retry
## Webhook Payload Format
All webhooks follow a consistent format:
```json
{
"id": "evt_abc123def456789",
"type": "post.created",
"created_at": "2026-01-15T10:30:00Z",
"data": {
"id": 123,
"title": "New Blog Post",
"status": "published",
"author_id": 42
},
"workspace_id": 456
}
```
**Fields:**
- `id` - Unique event identifier (use for idempotency)
- `type` - Event type (e.g., `post.created`, `user.updated`)
- `created_at` - ISO 8601 timestamp when the event occurred
- `data` - Event-specific payload
- `workspace_id` - Workspace that generated the event
## Webhook Headers
Every webhook request includes these headers:
| Header | Description | Example |
|--------|-------------|---------|
| `Content-Type` | Always `application/json` | `application/json` |
| `X-Webhook-Id` | Unique event ID | `evt_abc123def456` |
| `X-Webhook-Event` | Event type | `post.created` |
| `X-Webhook-Timestamp` | Unix timestamp | `1705312200` |
| `X-Webhook-Signature` | HMAC-SHA256 signature | `a1b2c3d4e5f6...` |
## Signature Verification
**Always verify webhook signatures** to ensure requests are authentic and unmodified.
### Signature Algorithm
The signature is computed as:
```
signature = HMAC-SHA256(timestamp + "." + payload, secret)
```
Where:
- `timestamp` is the value of `X-Webhook-Timestamp` header
- `payload` is the raw request body (JSON string)
- `secret` is your webhook signing secret
### Verification Steps
1. Get the signature and timestamp from headers
2. Get the raw request body (do not parse JSON first)
3. Compute expected signature: `HMAC-SHA256(timestamp + "." + body, secret)`
4. Compare signatures using timing-safe comparison
5. Verify timestamp is within 5 minutes of current time
## Code Examples
### PHP (Laravel)
```php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class WebhookController extends Controller
{
/**
* Handle incoming webhooks.
*/
public function handle(Request $request)
{
// Step 1: Verify the signature
if (!$this->verifySignature($request)) {
Log::warning('Invalid webhook signature', [
'ip' => $request->ip(),
]);
return response()->json(['error' => 'Invalid signature'], 401);
}
// Step 2: Verify timestamp (replay protection)
if (!$this->verifyTimestamp($request)) {
Log::warning('Webhook timestamp too old');
return response()->json(['error' => 'Timestamp expired'], 401);
}
// Step 3: Check for duplicate events (idempotency)
$eventId = $request->input('id');
if ($this->isDuplicate($eventId)) {
// Already processed - return success to stop retries
return response()->json(['received' => true]);
}
// Step 4: Process the event
try {
$this->processEvent(
$request->input('type'),
$request->input('data')
);
// Mark event as processed
$this->markProcessed($eventId);
return response()->json(['received' => true]);
} catch (\Exception $e) {
Log::error('Webhook processing failed', [
'event_id' => $eventId,
'error' => $e->getMessage(),
]);
// Return 500 to trigger retry
return response()->json(['error' => 'Processing failed'], 500);
}
}
/**
* Verify the HMAC-SHA256 signature.
*/
protected function verifySignature(Request $request): bool
{
$signature = $request->header('X-Webhook-Signature');
$timestamp = $request->header('X-Webhook-Timestamp');
$payload = $request->getContent();
$secret = config('services.webhooks.secret');
if (!$signature || !$timestamp) {
return false;
}
// Compute expected signature
$signedPayload = $timestamp . '.' . $payload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
// Use timing-safe comparison
return hash_equals($expectedSignature, $signature);
}
/**
* Verify timestamp is within tolerance (5 minutes).
*/
protected function verifyTimestamp(Request $request): bool
{
$timestamp = (int) $request->header('X-Webhook-Timestamp');
$tolerance = 300; // 5 minutes
return abs(time() - $timestamp) <= $tolerance;
}
/**
* Check if event was already processed.
*/
protected function isDuplicate(string $eventId): bool
{
return cache()->has("webhook:processed:{$eventId}");
}
/**
* Mark event as processed (cache for 24 hours).
*/
protected function markProcessed(string $eventId): void
{
cache()->put("webhook:processed:{$eventId}", true, now()->addDay());
}
/**
* Process the webhook event.
*/
protected function processEvent(string $type, array $data): void
{
match ($type) {
'post.created' => $this->handlePostCreated($data),
'post.updated' => $this->handlePostUpdated($data),
'post.deleted' => $this->handlePostDeleted($data),
'user.created' => $this->handleUserCreated($data),
default => Log::info("Unhandled webhook type: {$type}"),
};
}
protected function handlePostCreated(array $data): void
{
// Sync to your database, trigger notifications, etc.
Log::info('Post created', $data);
}
protected function handlePostUpdated(array $data): void
{
Log::info('Post updated', $data);
}
protected function handlePostDeleted(array $data): void
{
Log::info('Post deleted', $data);
}
protected function handleUserCreated(array $data): void
{
Log::info('User created', $data);
}
}
```
**Route registration:**
```php
// routes/api.php
Route::post('/webhooks', [WebhookController::class, 'handle'])
->middleware('throttle:100,1'); // Rate limit webhook endpoint
```
### JavaScript (Node.js/Express)
```javascript
const express = require('express');
const crypto = require('crypto');
const app = express();
// Important: Use raw body for signature verification
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const payload = req.body; // Raw buffer
const secret = process.env.WEBHOOK_SECRET;
// Step 1: Verify signature
if (!verifySignature(payload, signature, timestamp, secret)) {
console.warn('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Step 2: Verify timestamp
if (!verifyTimestamp(timestamp)) {
console.warn('Webhook timestamp too old');
return res.status(401).json({ error: 'Timestamp expired' });
}
// Step 3: Parse the event
let event;
try {
event = JSON.parse(payload.toString());
} catch (e) {
return res.status(400).json({ error: 'Invalid JSON' });
}
// Step 4: Check for duplicates
if (await isDuplicate(event.id)) {
return res.json({ received: true });
}
// Step 5: Process the event
try {
await processEvent(event.type, event.data);
await markProcessed(event.id);
res.json({ received: true });
} catch (e) {
console.error('Webhook processing failed:', e);
res.status(500).json({ error: 'Processing failed' });
}
});
function verifySignature(payload, signature, timestamp, secret) {
if (!signature || !timestamp) return false;
const signedPayload = timestamp + '.' + payload.toString();
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
} catch {
return false;
}
}
function verifyTimestamp(timestamp) {
const tolerance = 300; // 5 minutes
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - parseInt(timestamp)) <= tolerance;
}
// Redis-based duplicate detection
const Redis = require('ioredis');
const redis = new Redis();
async function isDuplicate(eventId) {
return await redis.exists(`webhook:processed:${eventId}`);
}
async function markProcessed(eventId) {
await redis.set(`webhook:processed:${eventId}`, '1', 'EX', 86400);
}
async function processEvent(type, data) {
switch (type) {
case 'post.created':
console.log('Post created:', data);
break;
case 'post.updated':
console.log('Post updated:', data);
break;
case 'post.deleted':
console.log('Post deleted:', data);
break;
default:
console.log(`Unhandled event type: ${type}`);
}
}
app.listen(3000);
```
### Python (Flask)
```python
import hmac
import hashlib
import time
import json
from functools import wraps
from flask import Flask, request, jsonify
import redis
app = Flask(__name__)
cache = redis.Redis()
WEBHOOK_SECRET = 'your_webhook_secret'
TIMESTAMP_TOLERANCE = 300 # 5 minutes
def verify_webhook(f):
"""Decorator to verify webhook signatures."""
@wraps(f)
def decorated(*args, **kwargs):
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
payload = request.get_data()
# Verify signature
if not verify_signature(payload, signature, timestamp):
return jsonify({'error': 'Invalid signature'}), 401
# Verify timestamp
if not verify_timestamp(timestamp):
return jsonify({'error': 'Timestamp expired'}), 401
return f(*args, **kwargs)
return decorated
def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
"""Verify the HMAC-SHA256 signature."""
if not signature or not timestamp:
return False
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(expected_signature, signature)
def verify_timestamp(timestamp: str) -> bool:
"""Verify timestamp is within tolerance."""
try:
ts = int(timestamp)
return abs(time.time() - ts) <= TIMESTAMP_TOLERANCE
except (ValueError, TypeError):
return False
def is_duplicate(event_id: str) -> bool:
"""Check if event was already processed."""
return cache.exists(f"webhook:processed:{event_id}")
def mark_processed(event_id: str) -> None:
"""Mark event as processed (24 hour TTL)."""
cache.setex(f"webhook:processed:{event_id}", 86400, "1")
@app.route('/webhooks', methods=['POST'])
@verify_webhook
def handle_webhook():
event = request.get_json()
event_id = event.get('id')
event_type = event.get('type')
data = event.get('data')
# Check for duplicates
if is_duplicate(event_id):
return jsonify({'received': True})
# Process the event
try:
process_event(event_type, data)
mark_processed(event_id)
return jsonify({'received': True})
except Exception as e:
app.logger.error(f"Webhook processing failed: {e}")
return jsonify({'error': 'Processing failed'}), 500
def process_event(event_type: str, data: dict) -> None:
"""Process webhook event based on type."""
handlers = {
'post.created': handle_post_created,
'post.updated': handle_post_updated,
'post.deleted': handle_post_deleted,
'user.created': handle_user_created,
}
handler = handlers.get(event_type)
if handler:
handler(data)
else:
app.logger.info(f"Unhandled event type: {event_type}")
def handle_post_created(data: dict) -> None:
app.logger.info(f"Post created: {data}")
def handle_post_updated(data: dict) -> None:
app.logger.info(f"Post updated: {data}")
def handle_post_deleted(data: dict) -> None:
app.logger.info(f"Post deleted: {data}")
def handle_user_created(data: dict) -> None:
app.logger.info(f"User created: {data}")
if __name__ == '__main__':
app.run(port=3000)
```
## Retry Handling
### Retry Schedule
Failed webhook deliveries are automatically retried with exponential backoff:
| Attempt | Delay | Total Time |
|---------|-------|------------|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 minutes |
| 4 | 30 minutes | 36 minutes |
| 5 | 2 hours | 2h 36m |
| 6 (final) | 24 hours | 26h 36m |
After 6 failed attempts, the delivery is marked as permanently failed.
### Triggering Retries
A delivery is retried when your endpoint returns:
- **5xx status codes** (server errors)
- **Connection timeouts** (30 second default)
- **Connection refused/failed**
A delivery is **not** retried when:
- **2xx status codes** (success)
- **4xx status codes** (client errors - your endpoint rejected it)
### Best Practices for Reliability
**1. Return 200 Quickly**
Process webhooks asynchronously to avoid timeouts:
```php
public function handle(Request $request)
{
// Verify signature first
if (!$this->verifySignature($request)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// Queue for async processing
ProcessWebhook::dispatch($request->all());
// Return immediately
return response()->json(['received' => true]);
}
```
**2. Handle Duplicates**
Webhooks may be delivered more than once. Always check the event ID:
```php
public function handle(Request $request)
{
$eventId = $request->input('id');
// Atomic check-and-set
if (!Cache::add("webhook:{$eventId}", true, now()->addDay())) {
// Already processed
return response()->json(['received' => true]);
}
// Process the event...
}
```
**3. Return 4xx for Permanent Failures**
If your endpoint cannot process an event (invalid data, etc.), return 4xx to stop retries:
```php
public function handle(Request $request)
{
$eventType = $request->input('type');
// Unknown event type - don't retry
if (!in_array($eventType, $this->supportedEvents)) {
return response()->json(['error' => 'Unknown event type'], 400);
}
// Process...
}
```
## Event Types
### Common Events
| Event | Description |
|-------|-------------|
| `{resource}.created` | Resource was created |
| `{resource}.updated` | Resource was updated |
| `{resource}.deleted` | Resource was deleted |
| `{resource}.published` | Resource was published |
| `{resource}.archived` | Resource was archived |
### Wildcard Subscriptions
Subscribe to all events for a resource:
```php
$webhook = WebhookEndpoint::create([
'url' => 'https://your-app.com/webhooks',
'events' => ['post.*'], // All post events
'secret' => 'whsec_' . Str::random(32),
]);
```
Subscribe to all events:
```php
$webhook = WebhookEndpoint::create([
'url' => 'https://your-app.com/webhooks',
'events' => ['*'], // All events
'secret' => 'whsec_' . Str::random(32),
]);
```
### High-Volume Events
Some events are high-volume and opt-in only:
- `link.clicked` - Link click tracking
- `qrcode.scanned` - QR code scan tracking
These must be explicitly included in the `events` array.
## Testing Webhooks
### Test Endpoint
Use the test endpoint to verify your webhook handler:
```bash
curl -X POST https://api.example.com/v1/webhooks/{webhook_id}/test \
-H "Authorization: Bearer sk_live_abc123"
```
This sends a test event to your endpoint.
### Local Development
For local development, use a tunnel service:
**ngrok:**
```bash
ngrok http 3000
# Use the https URL as your webhook endpoint
```
**Cloudflare Tunnel:**
```bash
cloudflared tunnel --url http://localhost:3000
```
### Mock Verification
Test signature verification in isolation:
```php
// tests/Feature/WebhookTest.php
public function test_verifies_valid_signature(): void
{
$payload = json_encode([
'id' => 'evt_test123',
'type' => 'post.created',
'data' => ['id' => 1, 'title' => 'Test'],
]);
$timestamp = time();
$secret = 'test_secret';
$signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
config(['services.webhooks.secret' => $secret]);
$response = $this->postJson('/webhooks', json_decode($payload, true), [
'X-Webhook-Signature' => $signature,
'X-Webhook-Timestamp' => $timestamp,
'Content-Type' => 'application/json',
]);
$response->assertOk();
}
public function test_rejects_invalid_signature(): void
{
$response = $this->postJson('/webhooks', [
'id' => 'evt_test123',
'type' => 'post.created',
], [
'X-Webhook-Signature' => 'invalid',
'X-Webhook-Timestamp' => time(),
]);
$response->assertUnauthorized();
}
```
## Troubleshooting
### Signature Verification Fails
**Common causes:**
1. **Parsed JSON instead of raw body**
```php
// Wrong - body has been modified
$payload = json_encode($request->all());
// Correct - raw body
$payload = $request->getContent();
```
2. **Different secrets**
- Check the secret matches exactly
- Ensure no extra whitespace
3. **Encoding issues**
```php
// Ensure UTF-8 encoding
$payload = $request->getContent();
$signedPayload = $timestamp . '.' . $payload;
```
### Deliveries Not Arriving
1. **Check endpoint URL** - Must be publicly accessible (not localhost)
2. **Check SSL certificate** - Must be valid and not expired
3. **Check firewall rules** - Allow incoming HTTPS from webhook IPs
4. **Check webhook is active** - Endpoints can be disabled after failures
### Timeouts
The default timeout is 30 seconds. If processing takes longer:
```php
// Queue long-running tasks
public function handle(Request $request)
{
// Quick signature check
if (!$this->verifySignature($request)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// Queue for async processing
ProcessWebhook::dispatch($request->all());
// Return immediately
return response()->json(['received' => true]);
}
```
## Security Considerations
### Always Verify Signatures
Never skip signature verification, even in development:
```php
// DON'T DO THIS
if (app()->environment('local')) {
return; // Skip verification
}
```
### Use HTTPS
Webhook endpoints must use HTTPS to protect:
- The webhook secret in transit
- Sensitive payload data
### Protect Your Secret
- Store in environment variables, not code
- Rotate secrets periodically
- Use different secrets per environment
### Rate Limit Your Endpoint
Protect against abuse:
```php
Route::post('/webhooks', [WebhookController::class, 'handle'])
->middleware('throttle:100,1'); // 100 requests per minute
```
## Learn More
- [Webhooks Overview](/packages/api/webhooks) - Creating webhook endpoints
- [Authentication](/packages/api/authentication) - API key management
- [Rate Limiting](/packages/api/rate-limiting) - Understanding rate limits

499
docs/webhooks.md Normal file
View file

@ -0,0 +1,499 @@
# Webhooks
The API package provides event-driven webhooks with HMAC-SHA256 signatures, automatic retries, and delivery tracking.
## Overview
Webhooks allow your application to:
- Send real-time notifications to external systems
- Trigger workflows in other applications
- Sync data across platforms
- Build integrations without polling
## Creating Webhooks
### Basic Webhook
```php
use Mod\Api\Models\WebhookEndpoint;
$webhook = WebhookEndpoint::create([
'url' => 'https://your-app.com/webhooks',
'events' => ['post.created', 'post.updated'],
'secret' => 'whsec_'.Str::random(32),
'workspace_id' => $workspace->id,
'is_active' => true,
]);
```
### With Filters
```php
$webhook = WebhookEndpoint::create([
'url' => 'https://your-app.com/webhooks/posts',
'events' => ['post.*'], // All post events
'filters' => [
'status' => 'published', // Only published posts
],
]);
```
## Dispatching Events
### Manual Dispatch
```php
use Mod\Api\Services\WebhookService;
$webhookService = app(WebhookService::class);
$webhookService->dispatch('post.created', [
'id' => $post->id,
'title' => $post->title,
'url' => route('posts.show', $post),
'published_at' => $post->published_at,
]);
```
### From Model Events
```php
use Mod\Api\Services\WebhookService;
class Post extends Model
{
protected static function booted(): void
{
static::created(function (Post $post) {
app(WebhookService::class)->dispatch('post.created', [
'id' => $post->id,
'title' => $post->title,
]);
});
static::updated(function (Post $post) {
app(WebhookService::class)->dispatch('post.updated', [
'id' => $post->id,
'title' => $post->title,
]);
});
}
}
```
### From Actions
```php
use Mod\Blog\Actions\CreatePost;
use Mod\Api\Services\WebhookService;
class CreatePost
{
use Action;
public function handle(array $data): Post
{
$post = Post::create($data);
// Dispatch webhook
app(WebhookService::class)->dispatch('post.created', [
'post' => $post->only(['id', 'title', 'slug']),
]);
return $post;
}
}
```
## Webhook Payload
### Standard Format
```json
{
"id": "evt_abc123def456",
"type": "post.created",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"object": {
"id": 123,
"title": "My Blog Post",
"url": "https://example.com/posts/my-blog-post"
}
},
"workspace_id": 456
}
```
### Custom Payload
```php
$webhookService->dispatch('post.published', [
'post_id' => $post->id,
'title' => $post->title,
'author' => [
'id' => $post->author->id,
'name' => $post->author->name,
],
'metadata' => [
'published_at' => $post->published_at,
'word_count' => str_word_count($post->content),
],
]);
```
## Webhook Signatures
All webhook requests include HMAC-SHA256 signatures:
### Request Headers
```
X-Webhook-Signature: sha256=abc123def456...
X-Webhook-Timestamp: 1640995200
X-Webhook-ID: evt_abc123
```
### Verifying Signatures
```php
use Mod\Api\Services\WebhookSignature;
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('X-Webhook-Signature');
$secret = $webhook->secret;
if (!WebhookSignature::verify($payload, $signature, $secret)) {
abort(401, 'Invalid signature');
}
// Process webhook...
}
```
### Manual Verification
```php
$expectedSignature = 'sha256=' . hash_hmac(
'sha256',
$payload,
$secret
);
if (!hash_equals($expectedSignature, $providedSignature)) {
abort(401);
}
```
## Webhook Delivery
### Automatic Retries
Failed deliveries are automatically retried:
```php
// config/api.php
'webhooks' => [
'max_retries' => 3,
'retry_delay' => 60, // seconds
'timeout' => 10,
],
```
Retry schedule:
1. Immediate delivery
2. After 1 minute
3. After 5 minutes
4. After 30 minutes
### Delivery Status
```php
$deliveries = $webhook->deliveries()
->latest()
->limit(10)
->get();
foreach ($deliveries as $delivery) {
echo $delivery->status; // success, failed, pending
echo $delivery->status_code; // HTTP status code
echo $delivery->attempts; // Number of attempts
echo $delivery->response_body; // Response from endpoint
}
```
### Manual Retry
```php
use Mod\Api\Models\WebhookDelivery;
$delivery = WebhookDelivery::find($id);
if ($delivery->isFailed()) {
$delivery->retry();
}
```
## Webhook Events
### Common Events
| Event | Description |
|-------|-------------|
| `{resource}.created` | Resource created |
| `{resource}.updated` | Resource updated |
| `{resource}.deleted` | Resource deleted |
| `{resource}.published` | Resource published |
| `{resource}.archived` | Resource archived |
### Wildcards
```php
// All post events
'events' => ['post.*']
// All events
'events' => ['*']
// Specific events
'events' => ['post.created', 'post.published']
```
## Testing Webhooks
### Test Endpoint
```php
use Mod\Api\Models\WebhookEndpoint;
$webhook = WebhookEndpoint::find($id);
$result = $webhook->test([
'test' => true,
'message' => 'This is a test webhook',
]);
if ($result['success']) {
echo "Test successful! Status: {$result['status_code']}";
} else {
echo "Test failed: {$result['error']}";
}
```
### Mock Webhooks in Tests
```php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Mod\Api\Facades\Webhooks;
class PostCreationTest extends TestCase
{
public function test_dispatches_webhook_on_create(): void
{
Webhooks::fake();
$post = Post::create(['title' => 'Test']);
Webhooks::assertDispatched('post.created', function ($event, $payload) {
return $payload['id'] === $post->id;
});
}
}
```
## Webhook Consumers
### Receiving Webhooks (PHP)
```php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function handle(Request $request)
{
// Verify signature
if (!$this->verifySignature($request)) {
abort(401, 'Invalid signature');
}
$event = $request->input('type');
$data = $request->input('data');
match ($event) {
'post.created' => $this->handlePostCreated($data),
'post.updated' => $this->handlePostUpdated($data),
default => null,
};
return response()->json(['received' => true]);
}
protected function verifySignature(Request $request): bool
{
$payload = $request->getContent();
$signature = $request->header('X-Webhook-Signature');
$secret = config('webhooks.secret');
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}
}
```
### Receiving Webhooks (JavaScript/Node.js)
```javascript
const express = require('express');
const crypto = require('crypto');
app.post('/webhooks', express.raw({type: 'application/json'}), (req, res) => {
const payload = req.body;
const signature = req.headers['x-webhook-signature'];
const secret = process.env.WEBHOOK_SECRET;
// Verify signature
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
switch (event.type) {
case 'post.created':
handlePostCreated(event.data);
break;
case 'post.updated':
handlePostUpdated(event.data);
break;
}
res.json({received: true});
});
```
## Webhook Management UI
### List Webhooks
```php
$webhooks = WebhookEndpoint::where('workspace_id', $workspace->id)->get();
```
### Enable/Disable
```php
$webhook->update(['is_active' => false]); // Disable
$webhook->update(['is_active' => true]); // Enable
```
### View Deliveries
```php
$deliveries = $webhook->deliveries()
->with('webhookEndpoint')
->latest()
->paginate(50);
```
## Best Practices
### 1. Verify Signatures
```php
// ✅ Good - always verify
if (!WebhookSignature::verify($payload, $signature, $secret)) {
abort(401);
}
```
### 2. Return 200 Quickly
```php
// ✅ Good - queue long-running tasks
public function handle(Request $request)
{
// Verify signature
if (!$this->verifySignature($request)) {
abort(401);
}
// Queue processing
ProcessWebhook::dispatch($request->all());
return response()->json(['received' => true]);
}
```
### 3. Handle Idempotency
```php
// ✅ Good - check for duplicate events
public function handle(Request $request)
{
$eventId = $request->input('id');
if (ProcessedWebhook::where('event_id', $eventId)->exists()) {
return response()->json(['received' => true]); // Already processed
}
// Process webhook...
ProcessedWebhook::create(['event_id' => $eventId]);
}
```
### 4. Use Webhook Secrets
```php
// ✅ Good - secure secret
'secret' => 'whsec_' . Str::random(32)
// ❌ Bad - weak secret
'secret' => 'password123'
```
## Troubleshooting
### Webhook Not Firing
1. Check if webhook is active: `$webhook->is_active`
2. Verify event name matches: `'post.created'` not `'posts.created'`
3. Check workspace context is set
4. Review event filters
### Delivery Failures
1. Check endpoint URL is reachable
2. Verify SSL certificate is valid
3. Check firewall/IP whitelist
4. Review timeout settings
### Signature Verification Fails
1. Ensure using raw request body (not parsed JSON)
2. Check secret matches on both sides
3. Verify using same hashing algorithm (SHA-256)
4. Check for whitespace/encoding issues
## Learn More
- [API Authentication →](/packages/api/authentication)
- [Webhook Security →](/api/authentication#webhook-signatures)
- [API Reference →](/api/endpoints#webhook-endpoints)