Conectada el 6 de agosto de 2026 · gestión de pedidos, entregas y stock
La API beta ya permite crear y consultar pedidos. Lo que falta es operarlos. Este documento explica por qué hace falta, enseña cómo quedaría en el panel, lista las 21 operaciones que necesita un gestor de pedidos y trae el código de cada pieza.
Con lo publicado hoy en la documentación beta, una integración puede dar de alta un pedido, consultarlo y enterarse de que cambió de estado. Es un buen punto de partida para volcar pedidos desde una tienda. No alcanza, en cambio, para que una aplicación gestione esos pedidos: no hay forma de confirmarlos, de dejar constancia de una gestión, de corregir un dato de envío antes de que salga, ni siquiera de saber cuáles están pendientes.
Tres condiciones separan una API de volcado de una API sobre la que se puede construir, y son las mismas en cualquier plataforma que abre integraciones:
1 · Poder escribir, no solo leer. Un pedido no se queda quieto: se confirma, se rechaza, se aplaza, se le corrige la dirección, se le cambia la agencia. Hoy todo eso solo existe dentro del panel.
2 · Que un fallo se pueda leer. Cuando una operación no se puede aplicar, quien la pidió necesita saber por qué, en la propia respuesta y en un formato que un programa entienda. Un código de estado con el campo y el motivo ahorra días de soporte a los dos lados.
3 · Una identidad por aplicación, no un token pegado a mano. Mientras la credencial sea un secreto que el comerciante copia y pega, ustedes no pueden saber qué aplicación hace qué en su cuenta, ni revocar una sin romper las demás.
Lo que viene a continuación es esa lista: las operaciones que necesita cualquier gestor de pedidos, el orden en que las implementaríamos nosotros, y el código de las piezas que suelen dar más trabajo.
Conviene decirlo pronto porque cambia el tamaño del trabajo: no proponemos modificar nada de lo que ya tienen, sino añadir al lado.
El Auth-Token actual se queda y sigue siendo válido — OAuth se suma como segunda vía,
no lo sustituye. El webhook sigue enviando lo mismo, solo que además firmado y con
algún campo más; quien hoy lo reciba y no valide la firma seguirá funcionando igual. El
formato de respuesta ({"ok":1,"code":200,…}) se respeta tal cual, y los
endpoints nuevos lo usan. A getInfo se le piden campos adicionales — añadir
campos a un JSON no rompe a nadie que ya lo esté leyendo.
Dicho de otro modo: ninguna integración existente se cae por implementar esto. Todo lo de este documento se puede desplegar por partes, en el orden del punto 7, y cada pieza aporta por sí sola.
Hoy API Webhooks está al final del submenú de Configuración. La propuesta es poner Aplicaciones arriba del todo, justo debajo de Configuración: es lo primero que busca un comerciante que quiere conectar una herramienta, y desde ahí se gestiona todo — incluidos los webhooks, que dejan de configurarse a mano.
Conectada el 6 de agosto de 2026 · gestión de pedidos, entregas y stock
Al pulsar Desconectar, Dropi invalida el token y envía app.uninstalled a la
aplicación, que deja de trabajar sola. Hoy no existe forma de que se entere.
Y para que no haya que interpretar nada, así se compone esa tarjeta por dentro:
┌─────────────────────────────────────────────────────────────────────────┐ │ ┌────┐ OrderPilot [ACTIVA] ┌──────────────┐ │ │ │logo│ Conectada el 6 de agosto · qué hace │ Desconectar │ │ │ └────┘ ( Ver pedidos )( Cambiar estados )( … ) └──────────────┘ │ │ ● Webhook activo · último evento hace 2 min · 1.284 hoy │ └─────────────────────────────────────────────────────────────────────────┘ 48px contenido flexible botón └── grid de 3 columnas: logo · cuerpo · acción ──┘ /* Tres columnas y ya está: el logo fijo, el cuerpo elástico, el botón a la derecha */ .app-card{ display:grid; grid-template-columns:48px 1fr auto; gap:16px; align-items:start; padding:18px; border:1px solid #E7EAEF; border-radius:10px; background:#fff; } .app-logo{ width:48px; height:48px; border-radius:12px; object-fit:cover; } /* el estado, en verde y en mayúsculas pequeñas: se lee de un vistazo */ .badge--ok{ font-size:10.5px; font-weight:750; letter-spacing:.06em; text-transform:uppercase; padding:2px 8px; border-radius:5px; background:#EAF6F0; color:#48A57C; } /* los permisos, como etiquetas: en lenguaje humano, nunca "orders:write" */ .chips{ display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; } .chip{ font-size:11.5px; padding:3px 9px; border-radius:999px; background:#F4F6F9; color:#6B7684; border:1px solid #E7EAEF; } /* la salud del webhook: un punto de color dice más que un párrafo */ .health{ display:flex; align-items:center; gap:7px; margin-top:12px; font-size:12.5px; color:#6B7684; } .dot{ width:7px; height:7px; border-radius:50%; } .dot--ok { background:#48A57C; box-shadow:0 0 0 3px #48A57C22; } .dot--bad{ background:#F05252; box-shadow:0 0 0 3px #F0525222; } /* "Conectar otra aplicación": discontinuo, para que se lea como hueco y no como botón principal */ .btn--dashed{ display:block; width:100%; margin-top:12px; padding:14px; text-align:center; border:1.5px dashed #D3D9E0; border-radius:10px; color:#6B7684; font-weight:600; text-decoration:none; } .btn--dashed:hover{ border-color:#F26522; color:#F26522; background:#FEF1EA; }
Los colores son los suyos, sacados del propio panel — el naranja #F26522, el verde
de confirmado y los grises de los bordes. Si tienen un sistema de estilos propio, mejor usar el
suyo: lo único que importa es que se distingan de un vistazo quién está conectado, qué
puede hacer y si su webhook va bien.
El recorrido entero, para que se vea de dónde sale el botón. Empieza dentro de la aplicación, en su tienda:
Autorice a OrderPilot desde su propio panel de Dropi PRO. No comparte contraseñas ni claves: verá una pantalla con lo que vamos a poder hacer.
Le llevamos a su panel, autoriza allí, y vuelve aquí solo. Son dos clics.
↓ al pulsar, el navegador sale de la aplicación y va a dropipro.com ↓
↓ al autorizar, Dropi le devuelve a la aplicación ↓
Conectado. Ya no hay nada más que hacer aquí.
✓ Dropi PRO · España
El comerciante nunca comparte su token: autoriza dentro de Dropi, y Dropi decide qué puede hacer cada aplicación.
Conviene aclararlo porque es la duda que sale siempre: la conexión se inicia desde la aplicación, pero la autorización ocurre dentro de Dropi PRO. La pantalla de arriba no está incrustada en la aplicación — se visita, en el dominio de Dropi.
Es la misma mecánica que «Iniciar sesión con Google»: el botón está en la aplicación,
pero la pantalla de permisos es de Google y se ve en su dominio. Y eso es precisamente lo que
la hace fiable — el comerciante mira la barra de direcciones y comprueba que está en
dropipro.com de verdad, no en una copia.
| Dónde está el comerciante | Qué ocurre |
|---|---|
| En la aplicación | Pulsa Conectar con Dropi PRO |
| ↓ | El navegador sale de la aplicación |
dropipro.com | Ve la pantalla de permisos de arriba y pulsa Autorizar |
| ↓ | Dropi le devuelve a la aplicación con un código de un solo uso |
| En la aplicación | Se canjea el código por el token. Conectado |
Y la lista de Aplicaciones conectadas del punto 1 no es donde se conecta: es el registro posterior, donde el comerciante ve lo que tiene conectado y desde donde lo revoca. Son dos momentos distintos — autorizar una vez, y poder retirar el permiso siempre.
El mismo enlace funciona en las dos direcciones, y esta es la que más les interesa a ustedes: que un comerciante descubra la aplicación dentro de su panel. Al pulsar Conectar en el directorio, se le lleva a instalar la aplicación y, al terminar, vuelve a la pantalla de permisos de Dropi — el mismo paso de siempre, solo que empezando por el otro extremo.
Gestión de pedidos, entregas y stock · para tiendas Shopify
Un directorio así es, además, la forma natural de que ustedes decidan qué aplicaciones aparecen y con qué permisos puede pedir cada una.
| Si empieza en… | Recorrido |
|---|---|
| La aplicación | Conectar con Dropi PRO → autoriza en Dropi → vuelve conectado |
| El panel de Dropi PRO | Conectar en el directorio → instala la aplicación en su tienda → autoriza en Dropi → vuelve conectado |
Cambia por dónde entra, no lo que ocurre: la autorización siempre pasa por la pantalla de permisos de Dropi, y el token siempre queda ligado a esa tienda.
Lo que un gestor de pedidos necesita poder hacer. La columna Hoy refleja lo publicado en la documentación beta.
| Operación | Hoy | Endpoint propuesto |
|---|---|---|
| Acceso | ||
| Autorizar una aplicación y mantener el acceso | No | OAuth · /oauth/authorize + /oauth/token |
| Comprobar que la credencial sigue vigente | No | GET /api/me |
| Leer | ||
| Listar pedidos por estado y fecha | No | GET /api/orders |
| Consultar un pedido | Sí | POST /api/orders/getInfo/{id} — falta el identificador externo |
| Listar carritos abandonados | No | GET /api/abandoned-carts |
| Listar incidencias con su motivo literal | No | GET /api/incidences |
| Consultar catálogo — variantes, SKU, stock, coste y peso | No | GET /api/products |
| Estados válidos y sus transiciones | No | GET /api/catalog/statuses |
| Motivos válidos por estado | No | GET /api/catalog/reasons |
| Etiquetas de estado | No | GET /api/catalog/status-tags |
| Transportadoras disponibles y tarifas | No | GET /api/catalog/shipping-methods |
| Eventos de transporte, distinguidos de las notas de personas | Parcial | ampliar status_record: eventos de agencia + campo origin |
| Escribir | ||
| Crear un pedido | Sí | POST /api/orders/create |
| Cambiar el estado — confirmar, rechazar, dejar pendiente | No | POST /api/orders/{id}/status |
| Aplazar con fecha | No | mismo endpoint · postponed_date |
| Dejar constancia en el historial | No | POST /api/orders/{id}/notes |
| Corregir datos de envío — dirección, teléfono, ciudad, provincia, CP | No | PATCH /api/orders/{id} |
| Ajustar el importe del pedido | No | PATCH /api/orders/{id} · total |
| Asignar transportadora antes del despacho | No | PATCH /api/orders/{id} · shipping_method_id |
| Gestionar una incidencia | No | POST /api/incidences/{id}/manage |
| Registrar el webhook desde la propia aplicación | No | POST /api/webhooks |
Las cuatro resaltadas son las que, sin equivalente, obligan a que un humano entre al panel a terminar el trabajo — y por tanto las que impiden que la gestión sea automática.
Sobre todo para no confirmar pedidos que luego no se pueden preparar. Confirmar es mandar a preparación: si en ese momento no hay existencias de la variante concreta, el pedido entra en el circuito y se atasca más adelante. Poder comprobar el stock antes de confirmar evita ese pedido roto — y eso les ahorra trabajo a ustedes tanto como a nosotros.
Por eso conviene que GET /api/products devuelva las variantes, no solo el
producto padre: el stock vive en la variante, no en el producto. Y con ellas, dos datos más
que ya están en la ficha: el peso, para anticipar el porte antes de elegir
transportadora, y el coste, para cuadrar el pedido. Son datos que el comerciante ya ve
en su panel; se trata de poder leerlos sin entrar a mirarlos uno a uno.
Y qué se hace cuando no hay existencias: el pedido no se confirma — se deja en Pendiente de confirmación, con una nota que explica el motivo, y se avisa al cliente para preguntarle si prefiere esperar o anular. Nada se cancela por su cuenta y nada avanza a preparación sabiendo que va a atascarse. Para eso hacen falta, juntas, dos cosas de esta lista: consultar el stock y poder dejar el pedido en un estado con su motivo escrito.
getInfo y valen mucho
El identificador del pedido en la tienda de origen. El webhook ya lo envía como
shopify_order_id, pero la consulta no lo devuelve. Sin él no hay forma fiable de
emparejar un pedido de Dropi con el de la tienda: hay que adivinar por teléfono o por importe,
y un cliente que repite compra rompe esa suposición. Es un campo que ya existe en su sistema
y devolverlo no cuesta nada.
Las observaciones del pedido. getInfo devuelve
carrier_observations —las que lee el repartidor— pero no las observaciones
internas, que son las que llevan el contexto de la gestión.
La transportadora asignada. El webhook ya envía shipping_company, pero
getInfo no lo devuelve. Sin ese dato no se puede saber por qué agencia va cada
pedido sin esperar a que se mueva.
Y el más útil de los cuatro: quién escribió cada línea del historial. En
status_record conviven dos cosas distintas — los eventos del transporte
(«salió del centro logístico», «entrega fallida») y las anotaciones de personas. Una
integración que avise al cliente necesita distinguirlas: si las confunde, acaba mandando un
aviso porque alguien escribió una nota interna. Basta un campo por línea que diga de dónde
viene: "origin": "carrier" · "agent" · "api".
En la misma línea, GET /api/incidences debería devolver el motivo literal
de cada incidencia —el texto que se ve en el panel—, no solo su identificador: es lo que
permite decidir qué hacer con ella sin tener que interpretar.
El desplegable del panel ofrece cinco estados — los que una persona puede seleccionar. Pero por el ciclo de un pedido pasan bastantes más: Preparado, En ruta, Entregado, Devuelto… Esos no los elige nadie: los pone el transporte, y llegan por webhook.
Una integración necesita entenderlos todos, así que GET /api/catalog/statuses
debería devolver el catálogo completo con su identificador, no solo los seleccionables. Basta
una marca por estado ("selectable": true/false) para distinguir cuáles puede
pedir una aplicación y cuáles solo observa.
Y un detalle que conviene mirar: en la documentación, el ejemplo del webhook trae
"status_name": "En tránsito", mientras que el panel muestra «En ruta»
para ese momento del envío. Puede ser solo el ejemplo, pero si los nombres no coinciden
exactamente entre el panel y el webhook, cualquier integración que se guíe por el texto se
rompe en silencio. Con el catálogo de identificadores el problema desaparece: se
compara por status_id y el nombre pasa a ser lo que es, una etiqueta para
enseñar al usuario.
No tenemos el código fuente de su panel, así que esto no puede encajar tal cual: son piezas de referencia escritas en Laravel —como su panel— que habrá que adaptar a sus modelos, sus nombres y sus convenciones. Lo escribimos así para ahorrarles el trabajo de partir de cero, no para decirles cómo tienen que hacerlo.
Route::post('/orders/{order}/status', [OrderApiController::class, 'updateStatus']) ->middleware(['auth.api', 'scope:orders:write']); public function updateStatus(Request $r, Order $order) { $datos = $r->validate([ 'status_id' => ['required', 'integer', Rule::exists('statuses', 'id')], 'reason_id' => ['nullable', 'integer'], 'postponed_date' => ['nullable', 'date'], // para APLAZAR 'tag_ids' => ['nullable', 'array'], // etiquetas de estado 'details' => ['nullable', 'string', 'max:500'], 'idempotency_key' => ['nullable', 'string', 'max:100'], ]); // 1) la misma clave no se aplica dos veces (evita duplicados si hay reintento) if ($clave = $datos['idempotency_key'] ?? null) { if ($previo = ApiIdempotency::where('key', $clave)->first()) { return response()->json($previo->response, $previo->status_code); } } // 2) transición permitida if (!$order->puedeTransicionarA($datos['status_id'])) { return response()->json(['ok' => 0, 'code' => 409, 'error' => 'invalid_transition'], 409); } // 3) 🔴 LO MÁS IMPORTANTE DE TODO EL DOCUMENTO: // si una validación falla, DECIR CUÁL. Hoy el panel responde 200 y no cambia // nada, así que la integración reintenta a ciegas sin saber por qué. if ($fallo = $order->validarParaEstado($datos['status_id'])) { return response()->json(['ok' => 0, 'code' => 422, 'error' => 'validation_failed', 'message' => $fallo->mensaje, // "El teléfono debe tener al menos 10 dígitos" 'field' => $fallo->campo, // "phone" ], 422); } // 4) aplicar + historial, con el mismo formato que escribe un asesor DB::transaction(function () use ($order, $datos, $r) { $anterior = $order->status_id; $order->update(array_filter([ 'status_id' => $datos['status_id'], 'postponed_date' => $datos['postponed_date'] ?? null, ])); if (!empty($datos['tag_ids'])) $order->statusTags()->sync($datos['tag_ids']); OrderStatusRecord::create([ 'order_id' => $order->id, 'old_status_id' => $anterior, 'current_status_id' => $datos['status_id'], 'substatus_id' => $datos['reason_id'] ?? null, 'details' => $datos['details'] ?? null, 'created_by' => $r->user()->id, 'source' => 'api', // distingue humano de integración 'app_id' => $r->oauthClientId(), ]); }); return response()->json(['ok' => 1, 'code' => 200, 'order' => ['id' => $order->id, 'status_id' => $order->status_id]]); }
// Las cuatro cosas que hoy hacemos posteando el formulario entero del panel. // Todas ANTES del despacho; después, 409. public function update(Request $r, Order $order) { if ($order->fulfilled_at) { return response()->json(['ok' => 0, 'code' => 409, 'error' => 'order_already_dispatched'], 409); } $datos = $r->validate([ 'address' => ['nullable', 'string', 'max:120'], 'address_2' => ['nullable', 'string', 'max:120'], 'city' => ['nullable', 'string', 'max:80'], 'province' => ['nullable', 'string', 'max:80'], 'zip' => ['nullable', 'string', 'max:12'], // 🔴 el teléfono: validarlo POR PAÍS, no con un número fijo (ver punto 8) 'phone' => ['nullable', new TelefonoValido($order->country_code)], // el recargo del contra reembolso 'total' => ['nullable', 'numeric', 'min:0'], // elegir la agencia que mejor entrega en esa zona 'shipping_method_id' => ['nullable', 'integer', Rule::exists('shipping_methods', 'id')], 'carrier_observations' => ['nullable', 'string', 'max:255'], ]); $order->update(array_filter($datos, fn($v) => $v !== null)); $order->registrarCambio($r->oauthClientId(), $datos); // trazabilidad return response()->json(['ok' => 1, 'code' => 200, 'order' => new OrderResource($order)]); }
public function index(Request $r) { $q = Order::where('user_id', $r->user()->id); if ($s = $r->query('status_id')) $q->whereIn('status_id', explode(',', $s)); if ($de = $r->query('from')) $q->where('created_at', '>=', $de); if ($a = $r->query('to')) $q->where('created_at', '<=', $a); // el parámetro más útil: sincronizar sin repasarlo todo en cada pasada if ($d = $r->query('updated_since')) $q->where('updated_at', '>', $d); $p = $q->orderBy('updated_at', 'desc') ->paginate(min((int) $r->query('per_page', 50), 100)); return response()->json(['ok' => 1, 'code' => 200, 'orders' => OrderResource::collection($p->items()), 'pagination' => ['page' => $p->currentPage(), 'per_page' => $p->perPage(), 'total' => $p->total(), 'total_pages' => $p->lastPage()]]); }
// Los cuatro siguen el mismo patrón que `index()`: filtro del usuario, filtros opcionales, // paginación y el mismo sobre de respuesta. Se ponen para que no falte ninguno. public function carritosAbandonados(Request $r) // GET /api/abandoned-carts { $q = AbandonedCart::where('user_id', $r->user()->id); if ($d = $r->query('updated_since')) $q->where('updated_at', '>', $d); // el importe, DESGLOSADO (ver §8.2): products + shipping + cod_surcharge = total return $this->paginado($q, CartResource::class, $r); } public function incidencias(Request $r) // GET /api/incidences { $q = Incidence::with('order')->where('user_id', $r->user()->id); if ($s = $r->query('status')) $q->whereIn('status', explode(',', $s)); // 🔴 con el MOTIVO LITERAL, no solo su id: es lo que permite decidir qué hacer return $this->paginado($q, IncidenceResource::class, $r); } public function gestionarIncidencia(Request $r, Incidence $inc) // POST …/manage { $d = $r->validate(['status' => ['required', 'integer'], 'comment' => ['nullable', 'string', 'max:500']]); $inc->update(['status' => $d['status']]); $inc->comentarios()->create(['texto' => $d['comment'] ?? null, 'origin' => 'api', 'app_id' => $r->oauthClientId()]); return response()->json(['ok' => 1, 'code' => 200]); } public function productos(Request $r) // GET /api/products { // 🔴 CON VARIANTES: el stock vive ahí, no en el producto padre $q = Product::with('variants:id,product_id,sku,stock,cost,weight'); if ($s = $r->query('sku')) $q->whereHas('variants', fn($v) => $v->where('sku', $s)); return $this->paginado($q, ProductResource::class, $r); } public function me(Request $r) // GET /api/me { return response()->json(['ok' => 1, 'code' => 200, 'user_id' => $r->user()->id, 'store_id' => $r->user()->store_id, 'country_code' => $r->user()->country_code, 'scopes' => $r->scopes(), 'token_expires_at' => $r->token()->expires_at, ]); }
// routes/web.php Route::get ('/app/configuration/applications', [AppsController::class, 'index']); Route::delete('/app/configuration/applications/{app}', [AppsController::class, 'revoke']); // AppsController.php public function index() { $apps = OauthAccessToken::with(['client', 'webhook']) ->where('user_id', auth()->id()) ->where('revoked', false) ->get() ->map(fn($t) => [ 'id' => $t->client->id, 'nombre' => $t->client->name, 'logo' => $t->client->logo_url, 'conectada' => $t->created_at, 'permisos' => $t->scopesLegibles(), // "Ver pedidos", no "orders:read" 'webhook' => [ 'activo' => $t->webhook?->consecutive_failures === 0, 'ultimo' => $t->webhook?->last_success_at, 'eventos' => $t->webhook?->events_today ?? 0, ], ]); return view('configuration.applications', compact('apps')); } public function revoke(OauthClient $app) { DB::transaction(function () use ($app) { // 1) avisar ANTES de invalidar, para que la app pueda pararse sola $app->webhookDe(auth()->id())?->enviar('app.uninstalled', [ 'store_id' => auth()->user()->store_id, ]); // 2) invalidar tokens y borrar su webhook OauthAccessToken::where('client_id', $app->id) ->where('user_id', auth()->id())->update(['revoked' => true]); $app->webhookDe(auth()->id())?->delete(); }); return back()->with('ok', 'Aplicación desconectada'); }
<div class="apps"> {{-- una tarjeta por aplicación conectada --}} @forelse ($apps as $app) <article class="app-card"> <img class="app-logo" src="{{ $app['logo'] }}" alt=""> <div class="app-body"> <h3>{{ $app['nombre'] }} <span class="badge badge--ok">Activa</span> </h3> <p class="muted">Conectada el {{ $app['conectada']->isoFormat('D [de] MMMM [de] YYYY') }}</p> {{-- permisos en lenguaje humano, no scopes técnicos --}} <div class="chips"> @foreach ($app['permisos'] as $p) <span class="chip">{{ $p }}</span> @endforeach </div> {{-- salud del webhook: que se vea de un vistazo si va o no --}} <p class="health"> <i class="dot {{ $app['webhook']['activo'] ? 'dot--ok' : 'dot--bad' }}"></i> @if ($app['webhook']['activo']) Webhook activo · último evento {{ $app['webhook']['ultimo']->diffForHumans() }} · {{ number_format($app['webhook']['eventos'], 0, ',', '.') }} eventos hoy @else Webhook con fallos — la aplicación no está recibiendo avisos @endif </p> </div> <form method="POST" action="{{ route('apps.revoke', $app['id']) }}" onsubmit="return confirm('¿Desconectar {{ $app['nombre'] }}?')"> @csrf @method('DELETE') <button class="btn btn--ghost">Desconectar</button> </form> </article> @empty <p class="muted">Todavía no tiene ninguna aplicación conectada.</p> @endforelse <a class="btn btn--dashed" href="{{ route('apps.directory') }}">+ Conectar otra aplicación</a> </div>
// Sin firma, quien adivine la URL puede inyectar estados falsos. public function send(Webhook $wh, string $event, array $payload): void { $body = json_encode([ 'event' => $event, 'event_id' => (string) Str::ulid(), // permite descartar duplicados 'sent_at' => now()->toIso8601String(), 'data' => $payload, ], JSON_UNESCAPED_UNICODE); $ts = (string) now()->timestamp; $sig = hash_hmac('sha256', $ts . '.' . $body, $wh->secret); dispatch(new DeliverWebhookJob($wh->id, $body, $ts, $sig))->onQueue('webhooks'); } // El job reintenta solo: 1 min · 5 min · 30 min · 2 h · 6 h public $tries = 5; public function backoff(): array { return [60, 300, 1800, 7200, 21600]; } public function handle(): void { $res = Http::timeout(10)->withHeaders([ 'X-Dropi-Signature' => 'sha256=' . $this->signature, 'X-Dropi-Timestamp' => $this->timestamp, 'X-Dropi-Event-Id' => $this->eventId, 'X-Dropi-Attempt' => $this->attempts(), // ¿es un reenvío? ])->withBody($this->body, 'application/json')->post($wh->url); if (!$res->successful()) { $wh->increment('consecutive_failures'); // a los 20 → pausar y avisar throw new \RuntimeException("webhook devolvió " . $res->status()); } $wh->update(['consecutive_failures' => 0, 'last_success_at' => now()]); }
$crudo = $request->getContent(); // el cuerpo CRUDO, sin decodificar $ts = $request->header('X-Dropi-Timestamp'); if (abs(time() - (int) $ts) > 300) abort(401); // corta reenvíos $esperada = hash_hmac('sha256', $ts . '.' . $crudo, $secreto); if (!hash_equals($esperada, $recibida)) abort(401); // tiempo constante
# La aplicación lo hace sola, justo después de autorizarse: POST /api/webhooks { "url": "https://app.orderpilot.com/hooks/dropi", "events": ["order.created", "order.status_changed", "order.address_changed", "incidence.opened", "cart.abandoned", "app.uninstalled"] } # Dropi responde con el secreto de firma (se enseña UNA sola vez): { "ok": 1, "id": "wh_01J8FQ2K", "secret": "whsec_a3f9…" }
De todos esos eventos, el que más carga les quita es order.created. Sin él, una
integración que quiera enterarse de los pedidos nuevos no tiene más remedio que
preguntar cada pocos minutos — muchas consultas a su servidor para que casi siempre la
respuesta sea «nada nuevo». Con el aviso, cero consultas en balde y el pedido se atiende al
instante.
Redirige a /oauth/authorize con su client_id, los permisos que necesita y un state aleatorio.
Ve la pantalla del punto 3, dentro de Dropi. Si acepta, vuelve con un código de un solo uso.
POST /oauth/token devuelve access_token, refresh_token y el store_id.
Sola, con el token recién obtenido. Nadie pega una URL a mano nunca más.
| Fase | Qué | Por qué en ese orden |
|---|---|---|
| 1 | POST /orders/{id}/status + errores 422 con motivo | desbloquea toda la operación |
| 2 | GET /orders con updated_since | permite sincronizar sin repasarlo todo |
| 3 | PATCH /orders/{id} (envío, teléfono, importe, transportadora) | sin esto se despachan pedidos que se sabe que fallarán |
| 4 | Firma HMAC y reintentos del webhook | sin esto no es apto para producción |
| 5 | GET /catalog/* (estados, motivos, transportadoras) | quita los IDs fijos del código de todos los integradores |
| 6 | Incidencias, carritos, catálogo, /me | completan el ciclo |
| 7 | OAuth y la pantalla de Aplicaciones | convierte la API en plataforma |
Con las fases 1 a 4 ya se puede integrar en producción.
Las incluimos porque les afectan con o sin esta propuesta. Es posible que la primera ya esté corregida cuando lean esto — la detectamos el 5 de agosto y no hemos vuelto a comprobarlo. Va por si acaso.
Síntoma: confirmar un pedido devuelve 200 OK, la página se recarga entera
y el estado no cambia. Sin error visible.
| Teléfono | Dígitos | Resultado |
|---|---|---|
635345429 | 9 | «El número debe tener 10 dígitos» · el estado no cambia |
34635345429 | 11 | sin error · pasa a Confirmado |
La validación exige 10 dígitos. Los móviles españoles y portugueses tienen 9. Dropi PRO nació en mercados donde el número nacional tiene 10 (Colombia) y la regla se aplica también en los paneles de España y Portugal, así que afecta a cualquier pedido de ES o PT cuyo teléfono llegue sin prefijo.
Son dos cosas distintas y la segunda es la grave:
La validación — convendría hacerla por país (country_code): ES y PT son 9
dígitos, CO son 10. O aceptar formato E.164 y normalizar del lado servidor.
El silencio — el mensaje existe y está bien redactado, pero viaja dentro de una recarga
de página, así que ninguna integración puede leerlo: desde fuera, la operación parece haber
salido bien. Es lo que resuelve el 422 con field y
message del punto 5 — y por qué insistimos en ese detalle.
El problema no es el importe, es que llega sin desglosar. Tanto
getInfo como el webhook devuelven un total a secas. Desde fuera no
hay forma de saber qué lleva dentro: si es solo el producto, si incluye el envío, o si
incorpora el recargo que algunos comerciantes cobran por el servicio de contra reembolso.
Por qué importa: quien aplique ese recargo necesita comprobar, pedido a pedido, que el total es el que el cliente aceptó pagar — sobre todo en los que vienen de carritos abandonados, donde el importe puede haberse formado en otro momento. Sin desglose, la única manera de comprobarlo es a mano; y una diferencia pequeña no se nota hasta que se cuadran las cuentas a fin de mes.
Es un arreglo de plataforma, no de cada tienda. Se implementa una vez y queda resuelto para todos los comerciantes a la vez. El único matiz es que no todos cobran recargo — unos lo cobran, otros lo asumen, otros solo en ciertos países — así que el importe no se puede fijar igual para todos, del mismo modo que hoy tampoco se fijan igual los precios.
Cómo lo resolveríamos, en tres piezas que van juntas:
1 · Que el sistema sepa cuánto cobra cada tienda. Un dato de configuración —por
ejemplo cod_surcharge, con su importe y los países donde aplica— que se rellena
una sola vez, como cualquier otro parámetro de la cuenta. Quien no cobre recargo lo deja a
cero y para él nada cambia.
2 · Que el carrito arrastre lo que el cliente aceptó. Al convertirse en pedido, el total debería ser el que el cliente vio al elegir contra reembolso, no el precio del producto suelto. Esta es la parte que arregla el problema de raíz para todo el mundo.
3 · Y devolver el importe desglosado, tanto en getInfo como en
GET /api/abandoned-carts. Con el desglose, cualquier integración cuadra cuentas
sin adivinar de dónde sale cada euro:
{
"subtotal": "…", // los productos
"shipping": "…", // el envío, si se cobra aparte
"cod_surcharge": "…", // el recargo del contra reembolso, si la tienda lo aplica
"total": "…", // lo que el cliente acepta pagar al repartidor
"currency": "EUR"
}
Los nombres de los campos son lo de menos — lo que hace falta es que el total venga separado en sus partes. Para quien no cobre recargo, ese campo va a cero y nada cambia respecto a hoy.
Lo incluimos porque suele ser la primera duda cuando se habla de avisar al cliente, y conviene dejarlo claro desde el principio: nosotros no vendemos mensajería ni revendemos números.
El número es del comerciante. Se conecta el mismo número de WhatsApp con el que ya vende, a través de su propia cuenta de Meta. No hay que comprar números nuevos ni montar nada en Business Manager: se enlaza con un clic y sigue siendo suyo.
La cuenta de Meta también es suya, y el coste igual. Los mensajes salen con su número y se facturan en su cuenta de Meta — céntimos por conversación, directamente de Meta al comerciante, sin intermediarios y sin margen por medio.
Las plantillas se registran solas. Al conectar, las plantillas de aviso se envían a revisión de Meta para ese número; el comerciante ve en revisión y, cuando Meta las aprueba —normalmente el mismo día—, quedan operativas.
Lo decimos porque marca la relación con ustedes: el canal de mensajería no pasa por Dropi ni les cuesta nada, y tampoco compite con nada suyo. Lo único que necesitamos de la API es saber qué ha pasado con el pedido para poder contarlo.
Este motor se ha construido sobre una tienda que vende con Dropi todos los días. Todo lo que hay en este documento —los estados, el teléfono, el recargo, las incidencias, el desglose del importe— sale de haberme dado de bruces con ello en producción, no de leerse una documentación. Por eso puedo ofrecer tres cosas concretas:
Probarlo antes que nadie. Según vayan saliendo endpoints, los usamos en producción contra pedidos reales y les reportamos lo que falle, con el caso concreto y cómo reproducirlo.
Escribir la documentación pública de lo que se implemente, con ejemplos que funcionen de verdad.
Ayudar con piezas sueltas. Si hay algún punto que les esté costando, nos pasan ese bloque concreto y se lo devolvemos adaptado y probado. Solo esa pieza — no necesitamos ver nada más de su código, igual que ustedes no necesitan ver el nuestro.