diff --git a/api/migrations/0005_category_product_subcategory.py b/api/migrations/0005_category_product_subcategory.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4bbd259ff6f7feccfd701f11cd06d86f45ef937
--- /dev/null
+++ b/api/migrations/0005_category_product_subcategory.py
@@ -0,0 +1,61 @@
+# Generated by Django 3.0.3 on 2020-03-16 11:31
+
+from decimal import Decimal
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0004_auto_20200309_1953'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Category',
+            fields=[
+                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50, verbose_name='name')),
+                ('image', models.ImageField(blank=True, null=True, upload_to='uploads/categories/', verbose_name='image')),
+            ],
+            options={
+                'verbose_name': 'category',
+                'verbose_name_plural': 'categories',
+                'ordering': ['name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='Subcategory',
+            fields=[
+                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50, verbose_name='name')),
+                ('image', models.ImageField(blank=True, null=True, upload_to='uploads/subcategories/', verbose_name='image')),
+                ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='api.Category', verbose_name='category')),
+            ],
+            options={
+                'verbose_name': 'subcategory',
+                'verbose_name_plural': 'subcategories',
+                'ordering': ['name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='Product',
+            fields=[
+                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=200, verbose_name='name')),
+                ('description', models.TextField(verbose_name='description')),
+                ('price', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='price')),
+                ('stock', models.PositiveIntegerField(verbose_name='stock')),
+                ('image', models.ImageField(blank=True, null=True, upload_to='uploads/products/', verbose_name='image')),
+                ('subcategory', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='products', to='api.Subcategory', verbose_name='subcategory')),
+            ],
+            options={
+                'verbose_name': 'product',
+                'verbose_name_plural': 'products',
+                'ordering': ['subcategory', 'name'],
+            },
+        ),
+    ]
diff --git a/api/models.py b/api/models.py
index 1eb8b6d418dd47fca9dc03a3a89cfcdc2db1e04e..b5b99ee27f7117698ba5736626ec9ae386f5de23 100644
--- a/api/models.py
+++ b/api/models.py
@@ -1,3 +1,4 @@
+import decimal
 import uuid
 
 from django.contrib.auth import models as auth_models
@@ -44,6 +45,85 @@ class User(auth_models.AbstractUser):
         return self.username
 
 
+class Category(db_models.Model):
+    id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID'))
+    name = db_models.CharField(max_length=50, verbose_name=_('name'))
+    image = db_models.ImageField(
+        blank=True,
+        null=True,
+        upload_to='uploads/categories/',
+        verbose_name=_('image')
+    )
+
+    class Meta:
+        ordering = ['name']
+        verbose_name = _('category')
+        verbose_name_plural = _('categories')
+
+    def __str__(self):
+        return self.name
+
+
+class Subcategory(db_models.Model):
+    id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID'))
+    name = db_models.CharField(max_length=50, verbose_name=_('name'))
+    category = db_models.ForeignKey(
+        Category,
+        on_delete=db_models.CASCADE,
+        related_name='subcategories',
+        verbose_name=_('category')
+    )
+    image = db_models.ImageField(
+        blank=True,
+        null=True,
+        upload_to='uploads/subcategories/',
+        verbose_name=_('image')
+    )
+
+    class Meta:
+        ordering = ['name']
+        verbose_name = _('subcategory')
+        verbose_name_plural = _('subcategories')
+
+    def __str__(self):
+        return self.name
+
+
+class Product(db_models.Model):
+    id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID'))
+    name = db_models.CharField(max_length=200, verbose_name=_('name'))
+    subcategory = db_models.ForeignKey(
+        Subcategory,
+        blank=True,
+        null=True,
+        on_delete=db_models.SET_NULL,
+        related_name='products',
+        verbose_name=_('subcategory')
+    )
+    description = db_models.TextField(verbose_name=_('description'))
+    price = db_models.DecimalField(
+        decimal_places=2,
+        max_digits=12,
+        validators=[validators.MinValueValidator(decimal.Decimal('0.01'))],
+        verbose_name=_('price')
+    )
+    stock = db_models.PositiveIntegerField(verbose_name=_('stock'))
+    image = db_models.ImageField(
+        blank=True,
+        null=True,
+        upload_to='uploads/products/',
+        verbose_name=_('image')
+    )
+
+    class Meta:
+        ordering = ['subcategory', 'name']
+        verbose_name = _('product')
+        verbose_name_plural = _('products')
+
+    def __str__(self):
+        return self.name
+
+
 class Program(db_models.Model):
     id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID'))
     name = db_models.CharField(max_length=200, verbose_name=_('name'))
diff --git a/api/serializers.py b/api/serializers.py
index 84d2b7bd04fbf6695639742db09871d383271af7..de6722b290d923eb2a3db0fda4000afe953a27c8 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -65,6 +65,44 @@ class UserSerializer(serializers.ModelSerializer):
         return super().validate(attrs)
 
 
+class ProductSerializer(serializers.ModelSerializer):
+    category = serializers.ReadOnlyField(source='subcategory.category.pk')
+    category_name = serializers.ReadOnlyField(source='subcategory.category.name')
+    subcategory_name = serializers.ReadOnlyField(source='subcategory.name')
+
+    class Meta:
+        fields = [
+            'id',
+            'name',
+            'category',
+            'category_name',
+            'subcategory',
+            'subcategory_name',
+            'description',
+            'price',
+            'stock',
+            'image',
+        ]
+        model = models.Product
+        read_only_fields = ['id']
+
+
+class SubcategorySerializer(serializers.ModelSerializer):
+    category_name = serializers.ReadOnlyField(source='category.name')
+
+    class Meta:
+        fields = ['id', 'name', 'category', 'category_name', 'image']
+        model = models.Subcategory
+        read_only_fields = ['id']
+
+
+class CategorySerializer(serializers.ModelSerializer):
+    class Meta:
+        fields = ['id', 'name', 'image']
+        model = models.Category
+        read_only_fields = ['id']
+
+
 class ProgramSerializer(serializers.ModelSerializer):
     class Meta:
         fields = [
diff --git a/api/tests.py b/api/tests.py
index d28f288b74495f029a6dbb7d20665fbbe5ec62ca..91aa600fc297086a2e6dfc0b7fbce800dfc03f28 100644
--- a/api/tests.py
+++ b/api/tests.py
@@ -25,6 +25,21 @@ USER_DATA = {
     'sub_district': 'Dummy Sub-District',
 }
 
+CATEGORY_DATA = {
+    'name': 'Dummy Category',
+}
+
+SUBCATEGORY_DATA = {
+    'name': 'Dummy Subcategory',
+}
+
+PRODUCT_DATA = {
+    'name': 'Dummy Product',
+    'description': 'Dummy description.',
+    'price': '1000.00',
+    'stock': 1,
+}
+
 PROGRAM_DATA = {
     'name': 'Dummy Program',
     'description': 'Dummy description.',
@@ -253,6 +268,106 @@ class UserTest(rest_framework_test.APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
 
+class ProductTest(rest_framework_test.APITestCase):
+    def setUp(self):
+        self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA)
+        self.superuser_http_authorization = get_http_authorization(
+            SUPERUSER_DATA['username'],
+            SUPERUSER_DATA['password']
+        )
+        self.category = models.Category.objects.create(**CATEGORY_DATA)
+        self.subcategory = models.Subcategory.objects.create(
+            **dict(CATEGORY_DATA, category=self.category)
+        )
+
+    def test_category_model_string_representation(self):
+        category = models.Category.objects.create(**CATEGORY_DATA)
+        self.assertEqual(str(category), CATEGORY_DATA['name'])
+
+    def test_subcategory_model_string_representation(self):
+        category = models.Category.objects.create(**CATEGORY_DATA)
+        subcategory = models.Subcategory.objects.create(**dict(SUBCATEGORY_DATA, category=category))
+        self.assertEqual(str(subcategory), SUBCATEGORY_DATA['name'])
+
+    def test_product_model_string_representation(self):
+        category = models.Category.objects.create(**CATEGORY_DATA)
+        subcategory = models.Subcategory.objects.create(**dict(SUBCATEGORY_DATA, category=category))
+        product = models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=subcategory))
+        self.assertEqual(str(product), PRODUCT_DATA['name'])
+
+    def test_product_list_success(self):
+        http_authorization = self.superuser_http_authorization
+        url = urls.reverse('product-list')
+        self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+    def test_product_detail_success(self):
+        product = models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=self.subcategory))
+        product_id = str(product.id)
+        http_authorization = self.superuser_http_authorization
+        url = urls.reverse('product-detail', args=[product_id])
+        self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+    def test_create_product_success(self):
+        http_authorization = self.superuser_http_authorization
+        data = PRODUCT_DATA
+        data['subcategory'] = self.subcategory.id
+        url = urls.reverse('product-list')
+        self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member
+        response = self.client.post(url, data, format='json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(models.Product.objects.count(), 1)
+        product_id = response.json()['id']
+        self.assertEqual(models.Product.objects.get(id=product_id).name, data['name'])
+
+    def test_create_product_fail(self):
+        http_authorization = self.superuser_http_authorization
+        url = urls.reverse('product-list')
+        self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member
+        response = self.client.post(url)
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(models.Product.objects.count(), 0)
+
+    def test_update_product_success(self):
+        http_authorization = self.superuser_http_authorization
+        data = PRODUCT_DATA
+        data['subcategory'] = self.subcategory.id
+        url = urls.reverse('product-list')
+        self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member
+        response = self.client.post(url, data, format='json')
+        product_id = response.json()['id']
+        data = {
+            'name': 'Dummy',
+        }
+        url = urls.reverse('product-detail', args=[product_id])
+        response = self.client.patch(url, data, format='json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(models.Product.objects.get(id=product_id).name, data['name'])
+        data = PRODUCT_DATA
+        data['subcategory'] = self.subcategory.id
+        response = self.client.put(url, data, format='json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(models.Product.objects.get(id=product_id).name, data['name'])
+
+    def test_update_product_fail(self):
+        http_authorization = self.superuser_http_authorization
+        data = PRODUCT_DATA
+        data['subcategory'] = self.subcategory.id
+        url = urls.reverse('product-list')
+        self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member
+        response = self.client.post(url, data, format='json')
+        product_id = response.json()['id']
+        data = {
+            'name': '',
+        }
+        url = urls.reverse('product-detail', args=[product_id])
+        response = self.client.patch(url, data, format='json')
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+
 class ProgramTest(rest_framework_test.APITestCase):
     def setUp(self):
         self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA)
diff --git a/api/urls.py b/api/urls.py
index c7db5eca8d23b3eedadd54a99199199d282ba097..7357c4dbf06f13eacb304450bf1edec856174ac5 100644
--- a/api/urls.py
+++ b/api/urls.py
@@ -17,6 +17,16 @@ urlpatterns = [
     urls.path('auth/logout-all/', knox_views.LogoutAllView.as_view(), name='auth-logout-all'),
     urls.path('users/', api_views.UserList.as_view(), name='user-list'),
     urls.path('users/<str:pk>/', api_views.UserDetail.as_view(), name='user-detail'),
+    urls.path('categories/', api_views.CategoryList.as_view(), name='category-list'),
+    urls.path('categories/<str:pk>/', api_views.CategoryDetail.as_view(), name='category-detail'),
+    urls.path('subcategories/', api_views.SubcategoryList.as_view(), name='subcategory-list'),
+    urls.path(
+        'subcategories/<str:pk>/',
+        api_views.SubcategoryDetail.as_view(),
+        name='subcategory-detail'
+    ),
+    urls.path('products/', api_views.ProductList.as_view(), name='product-list'),
+    urls.path('products/<str:pk>/', api_views.ProductDetail.as_view(), name='product-detail'),
     urls.path('programs/', api_views.ProgramList.as_view(), name='program-list'),
     urls.path('programs/<str:pk>/', api_views.ProgramDetail.as_view(), name='program-detail'),
     urls.path('config/', api_views.ConfigDetail.as_view(), name='config-detail'),
diff --git a/api/views.py b/api/views.py
index 2cdd99690dc68816f3bf1db19aa86a68aa3e2092..85437846e0c23555b22bf1dc3e99f17d8d850928 100644
--- a/api/views.py
+++ b/api/views.py
@@ -121,6 +121,87 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView):
         return super().get_object()
 
 
+class CategoryList(generics.ListCreateAPIView):
+    filter_backends = [
+        filters.OrderingFilter,
+        filters.SearchFilter,
+        rest_framework.DjangoFilterBackend,
+    ]
+    filterset_fields = ['name']
+    ordering_fields = ['name']
+    pagination_class = paginations.SmallResultsSetPagination
+    permission_classes = [
+        rest_framework_permissions.IsAuthenticated,
+        api_permissions.IsAdminUserOrReadOnly
+    ]
+    queryset = models.Category.objects.all()
+    search_fields = ['name']
+    serializer_class = api_serializers.CategorySerializer
+
+
+class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
+    permission_classes = [
+        rest_framework_permissions.IsAuthenticated,
+        api_permissions.IsAdminUserOrReadOnly
+    ]
+    queryset = models.Category.objects.all()
+    serializer_class = api_serializers.CategorySerializer
+
+
+class SubcategoryList(generics.ListCreateAPIView):
+    filter_backends = [
+        filters.OrderingFilter,
+        filters.SearchFilter,
+        rest_framework.DjangoFilterBackend,
+    ]
+    filterset_fields = ['name', 'category']
+    ordering_fields = ['name']
+    pagination_class = paginations.SmallResultsSetPagination
+    permission_classes = [
+        rest_framework_permissions.IsAuthenticated,
+        api_permissions.IsAdminUserOrReadOnly
+    ]
+    queryset = models.Subcategory.objects.all()
+    search_fields = ['name']
+    serializer_class = api_serializers.SubcategorySerializer
+
+
+class SubcategoryDetail(generics.RetrieveUpdateDestroyAPIView):
+    permission_classes = [
+        rest_framework_permissions.IsAuthenticated,
+        api_permissions.IsAdminUserOrReadOnly
+    ]
+    queryset = models.Subcategory.objects.all()
+    serializer_class = api_serializers.SubcategorySerializer
+
+
+class ProductList(generics.ListCreateAPIView):
+    filter_backends = [
+        filters.OrderingFilter,
+        filters.SearchFilter,
+        rest_framework.DjangoFilterBackend,
+    ]
+    filterset_fields = ['name', 'subcategory', 'subcategory__category']
+    ordering_fields = ['name', 'price', 'stock']
+    pagination_class = paginations.SmallResultsSetPagination
+    permission_classes = [
+        rest_framework_permissions.IsAuthenticated,
+        api_permissions.IsAdminUserOrReadOnly
+    ]
+    queryset = models.Product.objects.all()
+    search_fields = ['name']
+    serializer_class = api_serializers.ProductSerializer
+
+
+class ProductDetail(generics.RetrieveUpdateDestroyAPIView):
+    permission_classes = [
+        rest_framework_permissions.IsAuthenticated,
+        api_permissions.IsAdminUserOrReadOnly
+    ]
+    queryset = models.Product.objects.all()
+    serializer_class = api_serializers.ProductSerializer
+
+
 class ProgramList(generics.ListCreateAPIView):
     filter_backends = [
         filters.OrderingFilter,
diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo
index 33c40fff60af4b9bc07ced661c80e5cf57721eeb..42cb481a339fe44c7014cf8ddecc5110eee0fff7 100644
Binary files a/locale/id/LC_MESSAGES/django.mo and b/locale/id/LC_MESSAGES/django.mo differ
diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po
index ad93c510449c408a824f935c13b86f55df91e8df..4345211b67ba9d1b45b8ed9b3e5d4c7a15a6cb54 100644
--- a/locale/id/LC_MESSAGES/django.po
+++ b/locale/id/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-03-09 20:50+0700\n"
+"POT-Creation-Date: 2020-03-16 18:32+0700\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,105 +18,142 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=1; plural=0;\n"
 
-#: api/models.py:12 api/models.py:48 api/models.py:86
+#: api/models.py:13 api/models.py:49 api/models.py:68 api/models.py:93
+#: api/models.py:128 api/models.py:166
 msgid "ID"
 msgstr "ID"
 
-#: api/models.py:15
+#: api/models.py:16
 msgid "full name"
 msgstr "nama lengkap"
 
-#: api/models.py:16
+#: api/models.py:17
 msgid "phone number"
 msgstr "nomor telepon"
 
-#: api/models.py:17
+#: api/models.py:18
 msgid "address"
 msgstr "alamat"
 
-#: api/models.py:21
+#: api/models.py:22
 msgid "neighborhood"
 msgstr "RT"
 
-#: api/models.py:26
+#: api/models.py:27
 msgid "hamlet"
 msgstr "RW"
 
-#: api/models.py:28
+#: api/models.py:29
 msgid "urban village"
 msgstr "kelurahan"
 
-#: api/models.py:29
+#: api/models.py:30
 msgid "sub-district"
 msgstr "kecamatan"
 
-#: api/models.py:34
+#: api/models.py:35
 msgid "profile picture"
 msgstr "foto profil"
 
-#: api/models.py:36
+#: api/models.py:37
 msgid "OTP"
 msgstr "OTP"
 
-#: api/models.py:40
+#: api/models.py:41
 msgid "user"
 msgstr "pengguna"
 
-#: api/models.py:41
+#: api/models.py:42
 msgid "users"
 msgstr "pengguna"
 
-#: api/models.py:49
+#: api/models.py:50 api/models.py:69 api/models.py:94 api/models.py:129
 #, fuzzy
 #| msgid "full name"
 msgid "name"
 msgstr "nama lengkap"
 
-#: api/models.py:50
+#: api/models.py:55 api/models.py:80 api/models.py:115
+msgid "image"
+msgstr "gambar"
+
+#: api/models.py:60 api/models.py:74
+msgid "category"
+msgstr "kategori"
+
+#: api/models.py:61
+msgid "categories"
+msgstr "kategori"
+
+#: api/models.py:85 api/models.py:101
+msgid "subcategory"
+msgstr "subkategori"
+
+#: api/models.py:86
+msgid "subcategories"
+msgstr "subkategori"
+
+#: api/models.py:103 api/models.py:130
 msgid "description"
 msgstr "deskripsi"
 
-#: api/models.py:55
+#: api/models.py:108
+msgid "price"
+msgstr "harga"
+
+#: api/models.py:110
+msgid "stock"
+msgstr "stok"
+
+#: api/models.py:120
+msgid "product"
+msgstr "produk"
+
+#: api/models.py:121
+msgid "products"
+msgstr "produk"
+
+#: api/models.py:135
 msgid "start date and time"
 msgstr "tanggal dan waktu mulai"
 
-#: api/models.py:60
+#: api/models.py:140
 msgid "end date and time"
 msgstr "tanggal dan waktu berakhir"
 
-#: api/models.py:66
+#: api/models.py:146
 msgid "location"
 msgstr "lokasi"
 
-#: api/models.py:68
+#: api/models.py:148
 msgid "speaker"
 msgstr "pembicara"
 
-#: api/models.py:73
+#: api/models.py:153
 msgid "poster image"
 msgstr "gambar poster"
 
-#: api/models.py:78
+#: api/models.py:158
 msgid "program"
 msgstr "program"
 
-#: api/models.py:79
+#: api/models.py:159
 msgid "programs"
 msgstr "program"
 
-#: api/models.py:87
+#: api/models.py:167
 msgid "send SMS"
 msgstr "kirim SMS"
 
-#: api/models.py:90
+#: api/models.py:170
 msgid "config"
 msgstr "konfigurasi"
 
-#: api/models.py:91
+#: api/models.py:171
 msgid "configs"
 msgstr "konfigurasi"
 
-#: api/serializers.py:90
+#: api/serializers.py:126
 msgid "End date time should be greater than start date time."
 msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai."