Propuesta de OrderPilot para Dropi PRO

Qué necesita la API para que una aplicación pueda operar

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.

0 · Por qué hace falta la API

Lo importante

La documentación actual cubre el alta. Falta la gestión.

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.

Nada de esto obliga a cambiar lo que ya funciona

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.

1 · Dónde iría en el menú

Propuesta

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.

dropipro.com/app/configuration/applications

Aplicaciones

Préstamos: 0.00€ Disponible: 0.00€
OrderPilot Activa

Conectada el 6 de agosto de 2026 · gestión de pedidos, entregas y stock

Ver pedidosCambiar estados Anotar en el historialCorregir datos de envío Ajustar importeElegir transportadora Ver carritosVer catálogo y stock Gestionar incidencias
Webhook activo · último evento hace 2 minutos · 1.284 eventos hoy
2026 © Dropi PRO.

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:

Cómo se compone la tarjetacroquis + estilos, para que quede como en el punto 1
┌─────────────────────────────────────────────────────────────────────────┐
│  ┌────┐   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.

2 · Lo que sustituye

Pantalla actual
dropipro.com/app/configuration/webhooks

Configuración

Préstamos: 0.00€Disponible: 0.00€
Se configura para cada usuario. Un carácter mal pegado deja esa tienda sin avisos y nadie se entera: no hay firma que valide el origen, ni reintentos si el receptor está caído, ni forma de ver si sigue funcionando.
2026 © Dropi PRO.

3 · Lo que ve el comerciante al conectar

El recorrido entero, para que se vea de dónde sale el botón. Empieza dentro de la aplicación, en su tienda:

admin.shopify.com · aplicación instalada en la tienda
2

Su proveedor

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.

España ⌄ Conectar con Dropi PRO

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  ↓

admin.shopify.com · de vuelta en la aplicación

Su proveedor

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.

Dónde ocurre cada cosa

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 comercianteQué ocurre
En la aplicaciónPulsa Conectar con Dropi PRO
El navegador sale de la aplicación
dropipro.comVe 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ónSe 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.

Y al revés: empezar desde Dropi PRO

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.

dropipro.com/app/configuration/applications/directorio

Aplicaciones disponibles

Préstamos: 0.00€Disponible: 0.00€
OrderPilot

Gestión de pedidos, entregas y stock · para tiendas Shopify

Confirmación automática Avisos al cliente Incidencias Carritos abandonados

Un directorio así es, además, la forma natural de que ustedes decidan qué aplicaciones aparecen y con qué permisos puede pedir cada una.

2026 © Dropi PRO.
Si empieza en…Recorrido
La aplicaciónConectar con Dropi PRO → autoriza en Dropi → vuelve conectado
El panel de Dropi PROConectar 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.

4 · Las operaciones, punto por punto

Lo que un gestor de pedidos necesita poder hacer. La columna Hoy refleja lo publicado en la documentación beta.

OperaciónHoyEndpoint propuesto
Acceso
Autorizar una aplicación y mantener el accesoNoOAuth · /oauth/authorize + /oauth/token
Comprobar que la credencial sigue vigenteNoGET /api/me
Leer
Listar pedidos por estado y fechaNoGET /api/orders
Consultar un pedidoPOST /api/orders/getInfo/{id} — falta el identificador externo
Listar carritos abandonadosNoGET /api/abandoned-carts
Listar incidencias con su motivo literalNoGET /api/incidences
Consultar catálogo — variantes, SKU, stock, coste y pesoNoGET /api/products
Estados válidos y sus transicionesNoGET /api/catalog/statuses
Motivos válidos por estadoNoGET /api/catalog/reasons
Etiquetas de estadoNoGET /api/catalog/status-tags
Transportadoras disponibles y tarifasNoGET /api/catalog/shipping-methods
Eventos de transporte, distinguidos de las notas de personasParcialampliar status_record: eventos de agencia + campo origin
Escribir
Crear un pedidoPOST /api/orders/create
Cambiar el estado — confirmar, rechazar, dejar pendienteNoPOST /api/orders/{id}/status
Aplazar con fechaNomismo endpoint · postponed_date
Dejar constancia en el historialNoPOST /api/orders/{id}/notes
Corregir datos de envío — dirección, teléfono, ciudad, provincia, CPNoPATCH /api/orders/{id}
Ajustar el importe del pedidoNoPATCH /api/orders/{id} · total
Asignar transportadora antes del despachoNoPATCH /api/orders/{id} · shipping_method_id
Gestionar una incidenciaNoPOST /api/incidences/{id}/manage
Registrar el webhook desde la propia aplicaciónNoPOST /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.

Por qué hace falta consultar el catálogo

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.

Dos campos que faltan en 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 catálogo de estados: que estén todos, no solo los que se pueden elegir

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.

5 · El código de cada pieza

Una aclaración necesaria

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.

Cambiar el estado de un pedidoel que desbloquea todo lo demás
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]]);
}
Corregir datos del pedidodirección · teléfono · importe · transportadora
// 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)]);
}
Listar pedidosGET /api/orders
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 que faltancarritos · incidencias · catálogo · credencial
// 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,
    ]);
}
La pantalla de Aplicacionescontrolador + vista Blade
// 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');
}
La vistaresources/views/configuration/applications.blade.php
<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>
Firmar el webhooklo que hoy no tiene
// 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()]);
}
Cómo lo verifica quien lo recibepara su documentación pública
$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
Registrar el webhook al conectarasí el comerciante no pega nada a mano
# 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.

6 · El flujo completo

01

La app pide permiso

Redirige a /oauth/authorize con su client_id, los permisos que necesita y un state aleatorio.

02

El comerciante autoriza

Ve la pantalla del punto 3, dentro de Dropi. Si acepta, vuelve con un código de un solo uso.

03

Se canjea por un token

POST /oauth/token devuelve access_token, refresh_token y el store_id.

04

La app registra su webhook

Sola, con el token recién obtenido. Nadie pega una URL a mano nunca más.

7 · Por dónde empezar

FaseQuéPor qué en ese orden
1POST /orders/{id}/status + errores 422 con motivodesbloquea toda la operación
2GET /orders con updated_sincepermite sincronizar sin repasarlo todo
3PATCH /orders/{id} (envío, teléfono, importe, transportadora)sin esto se despachan pedidos que se sabe que fallarán
4Firma HMAC y reintentos del webhooksin esto no es apto para producción
5GET /catalog/* (estados, motivos, transportadoras)quita los IDs fijos del código de todos los integradores
6Incidencias, carritos, catálogo, /mecompletan el ciclo
7OAuth y la pantalla de Aplicacionesconvierte la API en plataforma

Con las fases 1 a 4 ya se puede integrar en producción.

8 · Dos cosas que detectamos en el panel

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.

8.1 · La validación de teléfono rechaza los móviles de España y Portugal

Síntoma: confirmar un pedido devuelve 200 OK, la página se recarga entera y el estado no cambia. Sin error visible.

TeléfonoDígitosResultado
6353454299«El número debe tener 10 dígitos» · el estado no cambia
3463534542911sin 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.

8.2 · El importe del carrito no incluye el recargo del contra reembolso

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:

El desglose que resolvería el problemaen getInfo y en abandoned-carts
{
  "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.

9 · Una aclaración sobre WhatsApp

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.

10 · En qué podemos echar una mano

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.