Mercado Pago webhook processor built with Slim 4. It receives, validates, logs, and stores payment notifications from in-person Point Smart terminals, and is also compatible with static QR payments. Designed as the bridge between MP and the POS software of a physical store.
To understand this project, it is strongly recommended to review the Mercado Pago documentation: https://www.mercadopago.com.ar/developers/es/docs/mp-point/overview
Note: This project is an AI-assisted refactor based on real-world development on the legacy MP API, supervised by me. See PLAN.md for the implementation decisions.
Point/QR compatibility: Mercado Pago is migrating legacy integrations to Orders API. This receiver supports
order/ordersfor Point and QR; local order creation remains Point-only. See the compatibility review (Spanish) for fixes, official sources, deployment requirements, and limitations.
- PHP 8.4.1+ with
pdo_mysql,json,mbstring,opensslextensions - MariaDB 10.6+ or MySQL 8.0+ (for
jsoncolumn compatibility, otherwise adjust thewebhook_logstable) - Composer 2.x
git clone https://github.com/Arian023/mp-webhook-api.git
cd mp-webhook-api
composer install
cp .env.example .envEdit .env with your credentials:
DB_HOST=localhost
DB_PORT=3306
DB_NAME=mp_webhook
DB_USER=root
DB_PASS=
API_AUTH_TOKEN=independent-local-credential
MP_ACCESS_TOKEN=APP_USR-...
MP_WEBHOOK_SECRET=your-webhook-secret- PHP 8.4.1 or higher
- Slim 4 — PSR-7/15 micro framework
- PHP-DI — Dependency injection container
- Medoo — Lightweight PDO-based database abstraction
- Guzzle 8 — HTTP client with retry middleware
- Monolog 3 — Logging to files, console, or database
- Phinx — Database migrations
- Carbon — Date/time handling
This project includes configuration for Lerd. Get started with:
lerd link # Register site at mp-webhook-api.test
lerd start # Start PHP-FPM, MariaDB, NginxMariaDB is available at lerd-mariadb-11-8:3306 from the containers (user: root, password: lerd).
Alternatively, you can use php.new or Laravel Herd on Windows / macOS to run the project quickly.
# Check code style without modifying files (Pint)
composer lint
# Apply formatting / refactorings (Rector, PHP 8.4)
composer format
composer refactor
# Static analysis (PHPStan level 5)
composer analyse
# Analysis, style and tests
composer testComposer resolves dependencies for PHP 8.4.1 through config.platform.php to use
maintained Symfony 8.1 components. CI checks PHP 8.4/8.5 with MySQL 8.4. After installation,
run composer check-platform-reqs to validate the actual PHP runtime.
- Configure an independent
API_AUTH_TOKENfor/api/*Bearer authentication;MP_ACCESS_TOKENis only used with Mercado Pago. There is no fallback. Your private.envis not automatically modified. - Order mutations and all refunds require
X-Idempotency-Key(1–64 letters, digits, hyphens or underscores). Persist one key per business operation and reuse it for all retries, including timeouts and local failures after provider success. Use a different key for a new operation; never reuse a key with a different body. - Request bodies must be JSON objects with
Content-Type: application/json. Positive amounts: integer centavos for Intents, up to two decimal places for Orders. Orders require an alphanumericexternal_reference(1–64 characters, hyphens/underscores allowed).expiration_timeuses ISO 8601,PT30S–PT3H. - Failed webhook processing rolls back domain writes and returns 503 to allow
redelivery. Repeated charges are updated rather than duplicated. Invalid/unknown
events remain logged and ignored with 200 for compatibility. Both
ordersand the legacyordertopic are accepted. - Provider 400/404/409/422 errors retain their status; other provider failures map to 502. Legacy Point mutations are not automatically retried.
composer test:mysqltests persistence using connection-local temporary tables without changing application tables. Regular tests skip these unlessRUN_MYSQL_TESTS=1. Production:APP_DEBUG=false,SKIP_WEBHOOK_VALIDATION=false, document rootpublic/, andcomposer install --no-dev --optimize-autoloader.
# Create the database
mysql -u root -p -e "CREATE DATABASE mp_webhook CHARACTER SET utf8mb4;"
# Run migrations
vendor/bin/phinx migrateThis diagram represents the processing flow of a received webhook notification, from intake to storage in the database. A webhook only provides the minimum data of an intent, payment, or order. To obtain the full information, calls are made to the Mercado Pago API.
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px' }}}%%
graph TB
subgraph "Mercado Pago"
MP[Point Terminal]
MPAPI[MP API<br/>api.mercadopago.com]
end
subgraph "mp-webhook-api (Slim 4)"
direction TB
WH[POST /webhook<br/>WebhookController]
WP[WebhookProcessor]
IA{isAuthorized?<br/>HMAC-SHA256}
IV{isValid?<br/>JSON schema}
IW[IntentWebhookHandler]
PAYH[PaymentWebhookHandler]
ORDH[OrderWebhookHandler]
LOG[webhook_logs]
end
subgraph "Infrastructure"
DB[(MySQL<br/>Medoo)]
MPClient[Guzzle Client<br/>+ Retry Middleware]
end
MP -->|"POST /webhook<br/>X-Signature"| WH
WH --> WP
WP --> IA
IA -->|invalid| WP
IA -->|valid| IV
IV -->|invalid| WP
IV -->|valid| IW
IV -->|valid| PAYH
IV -->|valid| ORDH
IW --> MPClient
PAYH --> MPClient
ORDH --> MPClient
MPClient --> MPAPI
IW --> DB
PAYH --> DB
ORDH --> DB
WP -->|log event| LOG
LOG --> DB
| Layer | Path | Description |
|---|---|---|
| HTTP | app/Http/Controllers/ |
Slim route handlers |
| Middleware | app/Http/Middleware/ |
Auth, logging, error handling |
| Application | app/Application/Services/ |
Business logic orchestration |
| Infrastructure | app/Infrastructure/ |
Database, MP API client, repositories |
POST /webhook?data.id={uuid}&type=point_integration_wh
Headers: X-Request-Id, X-Signature: ts=...,v1=...
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/intents?device_id={id} |
Create payment intent |
GET |
/api/intents/{id} |
Get intent status |
DELETE |
/api/intents/{id}/cancel?device_id={id} |
Cancel intent |
POST |
/api/intents/{id}/refund |
Refund intent's payment |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/payments/{id} |
Get payment details |
POST |
/api/payments/{id}/refund |
Refund payment |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/orders |
Create order (Orders API) |
GET |
/api/orders/{id} |
Get order details |
POST |
/api/orders/{id}/cancel |
Cancel order |
POST |
/api/orders/{id}/refund |
Refund order |
GET /health
Returns {"status":"ok","database":"connected"}.
See docs/examples/ for anonymized JSON responses from the Mercado Pago API. Useful as reference for webhook and payment structures.
Received when a Point terminal creates, completes, or cancels a payment intent.
sequenceDiagram
autonumber
participant MP as Mercado Pago
participant WH as POST /webhook
participant WL as RequestLogger
participant WP as WebhookProcessor
participant IA as isAuthorized()
participant IV as isValid()
participant IW as IntentWebhookHandler
participant DB as MySQL (Medoo)
MP->>WH: POST /webhook?data.id={uuid}&type=point_integration_wh
Note right of WH: Headers: X-Request-Id, X-Signature
WH->>WL: Log request (method, IP, path)
WL->>WP: process(request)
WP->>WP: Extract query params (data.id, type)
WP->>WP: Parse JSON body
WP->>IA: isAuthorized(request, dataId)
Note right of IA: Parse X-Signature: ts=...,v1=...<br/>HMAC-SHA256(secret, manifest)
IA-->>WP: true / false
WP->>IV: isValid(body, type)
Note right of IV: Check required fields:<br/>id, amount, state
IV-->>WP: true / false
alt Signature valid AND body valid
WP->>IW: handle(body, dataId)
IW->>DB: SELECT intents WHERE intent_id = dataId
alt Intent not found
IW->>DB: INSERT INTO intents (intent_id, amount, status, ...)
end
alt body.payment.id exists
IW->>MP: GET /v1/payments/{paymentId}
MP-->>IW: Payment details (200+ fields)
IW->>DB: SELECT payments WHERE payment_id = ?
alt Payment not found
IW->>DB: INSERT INTO payments (payment_id, status, card, ...)
IW->>DB: INSERT INTO charges (charge_id, type, amount, ...)
else Payment exists
IW->>DB: UPDATE payments SET status = ?, ...
end
IW->>DB: UPDATE intents SET payment_id = ?, payment_method = ?, ...
else No payment.id
IW->>DB: UPDATE intents SET intent_status = state
end
end
WP->>DB: INSERT INTO webhook_logs (event_type, payload, signature_valid, ...)
WP-->>WH: { message: "Webhook processed", type: "..." }
WH-->>MP: 200 OK
// Query: ?data.id=61ec4adf-...&type=point_integration_wh
// Body (state=FINISHED, has payment):
{
"additional_info": {"external_reference": "000910854661-000910029286"},
"amount": 3980000,
"created_at": "2026-06-28 20:39:45",
"id": "61ec4adf-82b3-414e-beb7-0308f131c63b",
"payment": {"id": 165404232933, "state": "approved", "type": "credit_card"},
"state": "FINISHED"
}Key: amount is in cents (divide by 100 for ARS).
Processing steps:
- Validate HMAC-SHA256 signature
- Find or create intent in
intentstable - If
payment.idexists → fetch full payment fromGET /v1/payments/{id} - Insert payment + charges into DB
- Link intent to payment
- Log event to
webhook_logs
Direct payment notification. Fetches payment details and stores them.
Order status notification. Receives order events like order.created, order.updated, order.canceled. Fetches the full order from MP API, stores/updates it in the local DB, and syncs the associated payments.
Other optional cases, such as Application Linking (mp-connect), Claims (topic_claims_integration_wh), and Chargebacks (topic_chargebacks_wh), are recorded in webhook_logs and responded to with HTTP 200 but are not processed. They are out of the project's scope and depend on their use in a CRM or payment system.
Six tables managed by Phinx migrations:
| Table | Description |
|---|---|
terminals |
Registered Point devices (external_id, pos_id, branch) |
intents |
Payment intents (intent_id UUID, amount, status, linked payment) |
payments |
Payment records (payment_id, card details, amounts, timestamps) |
charges |
Fee and tax breakdown per payment |
orders |
Orders API records (order_id, status, device_id, payment details) |
webhook_logs |
Full webhook event log (payload, signature, response code) |
erDiagram
terminals {
INT id PK
VARCHAR external_id UK "Device ID from MP"
VARCHAR pos_id "POS ID in MP"
VARCHAR serial_number "Device serial"
}
intents {
INT id PK
VARCHAR intent_id UK "MP intent UUID"
VARCHAR device_id "Terminal device_id"
DECIMAL amount "Amount in ARS"
VARCHAR intent_status "OPEN / FINISHED / CANCELED"
BIGINT payment_id "MP payment ID"
}
payments {
INT id PK
BIGINT payment_id UK "MP payment ID"
VARCHAR intent_id "MP intent UUID"
INT terminal_id FK "FK to terminals"
VARCHAR status "approved / rejected / refunded"
DECIMAL total_amount "Amount in ARS"
VARCHAR payment_method "visa, debvisa"
VARCHAR card_last_four "Last 4 digits"
DATETIME created_at_mp "date_created (tz converted)"
}
charges {
INT id PK
INT payment_id FK "FK to payments"
VARCHAR charge_id UK "MP charge ID"
VARCHAR type "fee / tax"
DECIMAL amount "Original amount"
DECIMAL rate "Rate percentage"
}
webhook_logs {
BIGINT id PK
VARCHAR request_id "X-Request-Id"
VARCHAR event_type "order / payment / point_integration_wh"
JSON payload "Full webhook body"
TINYINT signature_valid "0 or 1"
}
terminals ||--o{ intents : "device_id"
terminals ||--o{ payments : "terminal_id"
intents ||--o| payments : "payment_id"
payments ||--o{ charges : "payment_id"
mp-webhook-api/
├── app/
│ ├── Application/Services/ # WebhookProcessor, handlers, IntentService, PaymentService
│ ├── Http/
│ │ ├── Controllers/ # Webhook, Intent, Payment, Health
│ │ └── Middleware/ # BearerAuth, RequestLogger, ErrorHandler
│ └── Infrastructure/
│ ├── Database.php # Medoo factory
│ ├── Helpers/DateHelper.php # Timezone conversion
│ ├── MercadoPago/ # Guzzle client + exception
│ └── Repositories/ # Terminal, Intent, Payment, WebhookLog
├── bootstrap/ # Container, middleware, routes
├── database/migrations/ # Phinx migrations
├── docs/examples/ # API response examples
├── public/index.php # Front controller
├── phinx.php # Phinx config
├── pint.json # Laravel Pint config
├── rector.php # Rector config
└── phpstan.neon.dist # PHPStan config
This project is released under the MIT License.